├── test ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ └── apple-touch-icon-precomposed.png │ ├── 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_billing │ │ │ │ ├── payment_methods_controller.rb │ │ │ │ ├── charges_controller.rb │ │ │ │ └── subscriptions_controller.rb │ │ │ ├── paddle_classic │ │ │ │ ├── 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 │ │ │ ├── paddle_classic │ │ │ │ ├── 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 │ │ │ ├── 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 │ │ │ ├── controllers │ │ │ ├── application.js │ │ │ ├── index.js │ │ │ └── braintree_controller.js │ │ │ └── application.js │ ├── vendor │ │ └── javascript │ │ │ └── .keep │ ├── .browserslistrc │ ├── pay_test.db │ ├── 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 │ │ ├── storage.yml │ │ └── routes.rb │ ├── 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 │ │ │ ├── payment_failed_test.rb │ │ │ ├── payment_method_updated_test.rb │ │ │ ├── customer_deleted_test.rb │ │ │ ├── subscription_renewing_test.rb │ │ │ ├── checkout_session_completed_test.rb │ │ │ └── customer_updated_test.rb │ │ ├── error_test.rb │ │ ├── checkout_test.rb │ │ └── payment_method_test.rb │ ├── braintree │ │ ├── webhooks │ │ │ ├── subscription_canceled_test.rb │ │ │ ├── subscription_trial_ended_test.rb │ │ │ └── subscription_charged_successfully_test.rb │ │ └── error_test.rb │ ├── paddle_classic │ │ ├── webhooks │ │ │ ├── signature_verifier_test.rb │ │ │ └── subscription_payment_refunded_test.rb │ │ ├── error_test.rb │ │ ├── charge_test.rb │ │ └── billable_test.rb │ ├── paddle_billing │ │ ├── error_test.rb │ │ ├── billable_test.rb │ │ └── subscription_test.rb │ ├── fake_processor │ │ └── charge_test.rb │ ├── adapter_test.rb │ ├── billable │ │ └── sync_customer_test.rb │ └── stripe_test.rb ├── support │ ├── braintree.rb │ ├── fixtures │ │ ├── stripe │ │ │ ├── payment_methods │ │ │ │ └── link.json │ │ │ ├── customer.deleted.json │ │ │ ├── customer.updated.json │ │ │ ├── payment_method.attached.json │ │ │ ├── payment_method.detached.json │ │ │ └── payment_method.updated.json │ │ ├── braintree │ │ │ └── subscription_trial_ended.json │ │ └── paddle_classic │ │ │ ├── verification │ │ │ └── paddle_public_key.pem │ │ │ └── subscription_cancelled.json │ └── vcr.rb ├── jobs │ └── pay │ │ └── customer_sync_job_test.rb ├── routes │ └── webhooks_test.rb ├── controllers │ └── pay │ │ └── webhooks │ │ ├── paddle_classic_controller_test.rb │ │ ├── paddle_billing_controller_test.rb │ │ └── braintree_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_classic_controller.rb ├── models │ └── pay │ │ ├── application_record.rb │ │ ├── merchant.rb │ │ ├── payment_method.rb │ │ └── webhook.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 ├── .tool-versions ├── docs ├── braintree │ ├── 2_webhooks.md │ └── 1_overview.md ├── images │ └── logo.png ├── paddle_classic │ ├── 3_webhooks.md │ └── 2_javascript.md ├── paddle_billing │ └── 3_webhooks.md ├── 8_customizing_models.md ├── marketplaces │ └── braintree.md ├── stripe │ ├── 2_credentials.md │ ├── 5_webhooks.md │ └── 6_metered_billing.md └── 9_testing.md ├── lib ├── pay │ ├── 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 │ │ │ ├── payment_action_required.rb │ │ │ ├── subscription_trial_will_end.rb │ │ │ ├── customer_updated.rb │ │ │ ├── subscription_renewing.rb │ │ │ └── checkout_session_completed.rb │ ├── paddle_billing │ │ ├── error.rb │ │ ├── webhooks │ │ │ ├── transaction_completed.rb │ │ │ └── subscription.rb │ │ └── payment_method.rb │ ├── paddle_classic │ │ ├── error.rb │ │ ├── webhooks │ │ │ ├── subscription_created.rb │ │ │ ├── subscription_payment_refunded.rb │ │ │ ├── subscription_cancelled.rb │ │ │ └── subscription_updated.rb │ │ └── charge.rb │ ├── webhooks │ │ └── process_job.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 ├── generators │ └── pay │ │ ├── views_generator.rb │ │ └── email_views_generator.rb └── tasks │ └── pay.rake ├── .standard.yml ├── .overcommit.yml ├── bin ├── test_databases └── rails ├── Appraisals ├── .github ├── dependabot.yml ├── FUNDING.yml └── pull_request_template.md ├── .gitignore ├── .rubocop.yml ├── config └── routes.rb ├── Rakefile ├── pay.gemspec ├── gemfiles ├── rails_7.gemfile ├── rails_6_1.gemfile ├── rails_7_1.gemfile └── rails_main.gemfile ├── MIT-LICENSE └── Gemfile /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/pay/.keep: -------------------------------------------------------------------------------- 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/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.8.1" 3 | end 4 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs/pay/main/docs/images/logo.png -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/pay_test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs/pay/main/test/dummy/pay_test.db -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/helpers/pay/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 2.7 2 | ignore: 3 | - 'test/dummy/**/*' 4 | - 'lib/generators/active_record/templates/*' 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/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/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 | -------------------------------------------------------------------------------- /lib/pay/paddle_billing/error.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleBilling 3 | class Error < Pay::Error 4 | delegate :message, to: :cause 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/pay/paddle_classic/error.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 3 | class Error < Pay::Error 4 | delegate :message, to: :cause 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle_billing/payment_methods_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleBilling::PaymentMethodsController < ApplicationController 2 | def edit 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle_classic/payment_methods_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleClassic::PaymentMethodsController < ApplicationController 2 | def edit 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/app/views/paddle_classic/charges/show.html.erb: -------------------------------------------------------------------------------- 1 |

Paddle Classic Charge

2 | 3 |
<%= @charge.pretty_inspect %>
4 | 5 |
Actions
6 | <%= button_to_to "Refund", refund_paddle_classic_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 | -------------------------------------------------------------------------------- /lib/pay/paddle_billing/webhooks/transaction_completed.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleBilling 3 | module Webhooks 4 | class TransactionCompleted 5 | def call(event) 6 | Pay::PaddleBilling::Charge.sync(event.id) 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/pay/paddle_billing/webhooks/subscription.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleBilling 3 | module Webhooks 4 | class Subscription 5 | def call(event) 6 | Pay::PaddleBilling::Subscription.sync(event.id, object: event) 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/mailers/pay/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class ApplicationMailer < ActionMailer::Base 3 | def self.default_from_address 4 | Pay.support_email || ::ApplicationMailer.default_params[:from] 5 | end 6 | 7 | default from: default_from_address 8 | layout "mailer" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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-7-1" do 10 | gem "rails", "~> 7.1.0" 11 | end 12 | 13 | appraise "rails-main" do 14 | gem "rails", github: "rails/rails", branch: "main" 15 | end 16 | -------------------------------------------------------------------------------- /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/paddle_classic/webhooks/subscription_created.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 3 | module Webhooks 4 | class SubscriptionCreated 5 | def call(event) 6 | Pay::PaddleClassic::Subscription.sync(event.subscription_id, object: event) 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: activerecord 10 | versions: 11 | - 6.1.2.1 12 | - dependency-name: actionpack 13 | versions: 14 | - 6.1.2.1 15 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3* 5 | test/dummy/log/*.log 6 | test/dummy/tmp/ 7 | test/dummy/public/packs/ 8 | test/dummy/public/packs-test/ 9 | test/dummy/node_modules/ 10 | test/dummy/config/credentials.yml.enc 11 | test/dummy/config/master.key 12 | .DS_Store 13 | .byebug_history 14 | *.swp 15 | *.swo 16 | 17 | # Releases 18 | pay-*.gem 19 | .ruby-version 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | assert_equal false, merchant.onboarding_complete? 7 | 8 | merchant.onboarding_complete = true 9 | assert_equal true, merchant.onboarding_complete? 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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/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/stripe" 5 | 6 | // Bootstrap 7 | document.addEventListener("turbo:load", function() { 8 | $('[data-toggle="tooltip"]').tooltip() 9 | $('[data-toggle="popover"]').popover() 10 | }) 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle/payment_methods/edit.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Update Payment Information 9 |
10 | 11 |
12 |
13 |
14 | 15 | -------------------------------------------------------------------------------- /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/views/paddle_classic/payment_methods/edit.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Update Payment Information 9 |
10 | 11 |
12 |
13 |
14 | 15 | -------------------------------------------------------------------------------- /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/dummy/app/views/paddle_classic/charges/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Paddle Classic Charges

3 | 4 |
5 | <%= link_to "New Paddle Classic Charge", new_paddle_classic_charge_path, class: "btn btn-primary" %> 6 |
7 |
8 | 9 | <% @charges.each do |charge| %> 10 |
11 | <%= link_to "Pay::Charge #{charge.id}", paddle_classic_charge_path(charge) %> 12 |
13 | <% end %> 14 | -------------------------------------------------------------------------------- /docs/paddle_classic/3_webhooks.md: -------------------------------------------------------------------------------- 1 | # Paddle Classic Webhooks 2 | 3 | ## Endpoint 4 | 5 | The webhook endpoint for Paddle is `/pay/webhooks/paddle_classic` by default. 6 | 7 | ## Events 8 | 9 | Pay requires the following webhooks to properly sync charges and subscriptions as they happen. 10 | 11 | ```ruby 12 | subscription_created 13 | subscription_updated 14 | subscription_cancelled 15 | subscription_payment_succeeded 16 | subscription_payment_refunded 17 | ``` 18 | -------------------------------------------------------------------------------- /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/paddle_classic/webhooks/signature_verifier_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleClassic::Webhooks::SignatureVerifierTest < ActiveSupport::TestCase 4 | setup do 5 | @data = JSON.parse(File.read("test/support/fixtures/paddle_classic/subscription_created.json")) 6 | end 7 | 8 | test "webhook signature is verified correctly" do 9 | verifier = Pay::PaddleClassic::Webhooks::SignatureVerifier.new(@data) 10 | assert verifier.verify 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle_classic/subscriptions/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Paddle Classic Subscriptions

3 |
4 | <%= link_to "New Paddle Subscription", new_paddle_classic_subscription_path, class: "btn btn-primary" %> 5 |
6 |
7 | 8 | <% @subscriptions.each do |subscription| %> 9 |
10 | <%= link_to "Pay::Subscription #{subscription.id}", paddle_classic_subscription_path(subscription) %> 11 |
12 | <% end %> 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_billing", to: "pay/webhooks/paddle_billing#create" if Pay::PaddleBilling.enabled? 8 | post "webhooks/paddle_classic", to: "pay/webhooks/paddle_classic#create" if Pay::PaddleClassic.enabled? 9 | end 10 | -------------------------------------------------------------------------------- /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.presence || root_path 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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/paddle/subscriptions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Paddle Subscription

2 | 3 |
<%= @subscription.pretty_inspect %>
4 | 5 |
Actions
6 | <%= button_to "Resume", resume_paddle_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period? %> 7 | <%= button_to "Cancel at period end", cancel_paddle_subscription_path(@subscription), method: :patch, class: "d-block" %> 8 | <%= button_to "Cancel immediately", paddle_subscription_path(@subscription), method: :delete, class: "d-block" %> 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/pay/paddle_billing/error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleBilling::ErrorTest < ActiveSupport::TestCase 4 | test "re-raised paddle classic exceptions keep the same message" do 5 | exception = assert_raises { 6 | begin 7 | raise ::Paddle::Error, "The connection failed" 8 | rescue 9 | raise ::Pay::PaddleBilling::Error 10 | end 11 | } 12 | 13 | assert_equal "The connection failed", exception.message 14 | assert_equal ::Paddle::Error, exception.cause.class 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/pay/paddle_classic/error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleClassic::ErrorTest < ActiveSupport::TestCase 4 | test "re-raised paddle classic exceptions keep the same message" do 5 | exception = assert_raises { 6 | begin 7 | raise ::Paddle::Error, "The connection failed" 8 | rescue 9 | raise ::Pay::PaddleClassic::Error 10 | end 11 | } 12 | 13 | assert_equal "The connection failed", exception.message 14 | assert_equal ::Paddle::Error, exception.cause.class 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/paddle_billing/3_webhooks.md: -------------------------------------------------------------------------------- 1 | # Paddle Billing Webhooks 2 | 3 | ## Endpoint 4 | 5 | The webhook endpoint for Paddle Billing is `/pay/webhooks/paddle_billing` by default. 6 | 7 | ## Events 8 | 9 | Pay requires the following webhooks to properly sync charges and subscriptions as they happen. 10 | 11 | ```ruby 12 | subscription.activated 13 | subscription.canceled 14 | subscription.created 15 | subscription.imported 16 | subscription.past_due 17 | subscription.paused 18 | subscription.resumed 19 | subscription.trialing 20 | subscription.updated 21 | 22 | transaction.completed 23 | ``` 24 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle_classic/subscriptions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Paddle Classic Subscription

2 | 3 |
<%= @subscription.pretty_inspect %>
4 | 5 |
Actions
6 | <%= button_to "Resume", resume_paddle_classic_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period? %> 7 | <%= button_to "Cancel at period end", cancel_paddle_classic_subscription_path(@subscription), method: :patch, class: "d-block" %> 8 | <%= button_to "Cancel immediately", paddle_classic_subscription_path(@subscription), method: :delete, class: "d-block" %> 9 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle_classic/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/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/paddle/charges/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Buy now 4 | 5 | 15 |
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle_classic/charges/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Buy now 4 | 5 | 15 |
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /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/pay/paddle_billing/billable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleBilling::Billable::Test < ActiveSupport::TestCase 4 | setup do 5 | @pay_customer = pay_customers(:paddle_billing) 6 | end 7 | 8 | test "paddle cannot create a charge without options" do 9 | assert_raises(Pay::Error) { @pay_customer.charge(1000) } 10 | end 11 | 12 | test "retrieving a paddle billing subscription" do 13 | subscription = ::Paddle::Subscription.retrieve(id: "sub_01hd1drf5htjz45yt2346anmqt") 14 | assert_equal @pay_customer.processor_subscription("sub_01hd1drf5htjz45yt2346anmqt").id, subscription.id 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/pay/paddle_classic/webhooks/subscription_payment_refunded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 3 | module Webhooks 4 | class SubscriptionPaymentRefunded 5 | def call(event) 6 | pay_charge = Pay::Charge.find_by_processor_and_id(:paddle_classic, 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/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/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(data&.fetch("onboarding_complete")) || false 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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/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_billing: 22 | customer: paddle_billing 23 | processor_id: txn_123 24 | default: true 25 | type: card 26 | data: 27 | 28 | paddle_classic: 29 | customer: paddle_classic 30 | processor_id: 1000 31 | default: true 32 | type: card 33 | data: 34 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle/subscriptions/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Subscribe 4 | 5 | 22 |
23 |
24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/support/fixtures/stripe/payment_methods/link.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "pm_0O8JhSNFr9vQLFLbFtGgIs76", 3 | "object": "payment_method", 4 | "billing_details": { 5 | "address": { 6 | "city": "Cape Town", 7 | "country": "ZA", 8 | "line1": "1 Apple Way", 9 | "line2": null, 10 | "postal_code": "7708", 11 | "state": "" 12 | }, 13 | "email": "customer@example.org", 14 | "name": "Stripe Customer", 15 | "phone": null 16 | }, 17 | "billing_eligible": true, 18 | "created": 1699003800, 19 | "customer": "cus_1234", 20 | "link": { 21 | "email": "customer@example.org" 22 | }, 23 | "livemode": true, 24 | "metadata": {}, 25 | "setup_by": "seti_0O8JgkNFr9vQLFLbzJxzOyGj", 26 | "type": "link" 27 | } 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [excid3] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 billing webhook routes get mounted correctly" do 17 | post "/pay/webhooks/paddle_billing", as: :json 18 | assert_response :bad_request 19 | end 20 | 21 | test "paddle classic webhook routes get mounted correctly" do 22 | post "/pay/webhooks/paddle_classic", as: :json 23 | assert_response :bad_request 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /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", "~> 10.0" 14 | gem "paddle", "~> 2.1", ">= 2.1.1" 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 | -------------------------------------------------------------------------------- /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", "~> 10.0" 14 | gem "paddle", "~> 2.1", ">= 2.1.1" 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_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", "~> 10.0" 14 | gem "paddle", "~> 2.1", ">= 2.1.1" 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.1.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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", "~> 10.0" 14 | gem "paddle", "~> 2.1", ">= 2.1.1" 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_classic/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/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/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/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/controllers/pay/webhooks/paddle_classic_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Pay 4 | class PaddleClassicControllerTest < 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_classic_path 13 | assert_response :bad_request 14 | end 15 | 16 | test "should parse a paddle classic webhook" do 17 | user = User.create! 18 | params = fake_event "paddle_classic/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_classic_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/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/controllers/pay/webhooks/paddle_billing_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Pay 4 | class PaddleBillingControllerTest < 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_billing_path 13 | assert_response :bad_request 14 | end 15 | 16 | test "should parse a paddle billing webhook" do 17 | Pay::Webhooks::PaddleBillingController.any_instance.expects(:valid_signature?).returns(true) 18 | 19 | assert_difference("Pay::Webhook.count") do 20 | assert_enqueued_with(job: Pay::Webhooks::ProcessJob) do 21 | post webhooks_paddle_billing_path, params: fake_event("paddle_billing/subscription.created") 22 | assert_response :success 23 | end 24 | end 25 | 26 | assert_difference -> { pay_customers(:paddle_billing).subscriptions.count } do 27 | perform_enqueued_jobs 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/controllers/paddle_billing/charges_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleBilling::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_billing}).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_billing_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_billing_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/dummy/app/controllers/paddle_classic/charges_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleClassic::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_classic}).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_classic_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_classic_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/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 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request 2 | 3 | **Summary:** 4 | 5 | 6 | **Related Issue:** 7 | 8 | 9 | **Description:** 10 | 11 | 12 | **Testing:** 13 | 14 | 15 | **Screenshots (if applicable):** 16 | 17 | 18 | **Checklist:** 19 | 20 | 21 | - [ ] Code follows the project's coding standards 22 | - [ ] Tests have been added or updated to cover the changes 23 | - [ ] Documentation has been updated (if applicable) 24 | - [ ] All existing tests pass 25 | - [ ] Conforms to the contributing guidelines 26 | 27 | **Additional Notes:** 28 | 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 Classic", edit_paddle_classic_payment_method_path, class: "d-block" %> 27 |
28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/pay/paddle_classic/webhooks/subscription_payment_refunded_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleClassic::Webhooks::SubscriptionPaymentRefundedTest < ActiveSupport::TestCase 4 | setup do 5 | @data = OpenStruct.new JSON.parse(File.read("test/support/fixtures/paddle_classic/subscription_payment_refunded.json")) 6 | @pay_customer = pay_customers(:paddle_classic) 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::PaddleClassic::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::PaddleClassic::Webhooks::SubscriptionPaymentRefunded.new.call(@data) 19 | assert_nil charge.reload.amount_refunded 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /docs/paddle_classic/2_javascript.md: -------------------------------------------------------------------------------- 1 | # Paddle Classic 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 | -------------------------------------------------------------------------------- /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_classic" 24 | Pay::PaddleClassic::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 | -------------------------------------------------------------------------------- /test/models/pay/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Webhook::Test < ActiveSupport::TestCase 4 | test "rehydrates a Paddle Classic event" do 5 | pay_webhook = Pay::Webhook.create processor: :paddle_classic, event_type: :example, event: fake_event("paddle_classic/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/pay/paddle_billing/payment_method.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleBilling 3 | class PaymentMethod 4 | attr_reader :pay_payment_method 5 | 6 | delegate :customer, :processor_id, to: :pay_payment_method 7 | 8 | def self.sync(pay_customer:, attributes:) 9 | details = attributes.method_details 10 | attrs = { 11 | type: details.type.downcase 12 | } 13 | 14 | case details.type.downcase 15 | when "card" 16 | attrs[:brand] = details.card.type 17 | attrs[:last4] = details.card.last4 18 | attrs[:exp_month] = details.card.expiry_month 19 | attrs[:exp_year] = details.card.expiry_year 20 | end 21 | 22 | payment_method = pay_customer.payment_methods.find_or_initialize_by(processor_id: attributes.stored_payment_method_id) 23 | payment_method.update!(attrs) 24 | payment_method 25 | end 26 | 27 | def initialize(pay_payment_method) 28 | @pay_payment_method = pay_payment_method 29 | end 30 | 31 | # Sets payment method as default 32 | def make_default! 33 | end 34 | 35 | # Remove payment method 36 | def detach 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /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 | stripe_customer = pay_customer.customer 13 | 14 | # Sync default card 15 | if (payment_method_id = stripe_customer.invoice_settings.default_payment_method) 16 | Pay::Stripe::PaymentMethod.sync(payment_method_id, stripe_account: event.try(:account)) 17 | 18 | else 19 | # No default payment method set 20 | pay_customer.payment_methods.update_all(default: false) 21 | end 22 | 23 | # Sync invoice credit balance and currency 24 | if stripe_customer.invoice_credit_balance.present? 25 | pay_customer.update( 26 | invoice_credit_balance: stripe_customer.invoice_credit_balance, 27 | currency: stripe_customer.currency 28 | ) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/controllers/pay/webhooks/paddle_classic_controller.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Webhooks 3 | class PaddleClassicController < 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::PaddleClassic::Error 12 | head :bad_request 13 | end 14 | 15 | private 16 | 17 | def queue_event(event) 18 | return unless Pay::Webhooks.delegator.listening?("paddle_classic.#{params[:alert_name]}") 19 | 20 | record = Pay::Webhook.create!(processor: :paddle_classic, 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::PaddleClassic::Webhooks::SignatureVerifier.new(event) 27 | return event if verifier.verify 28 | raise Pay::PaddleClassic::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 | -------------------------------------------------------------------------------- /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/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 classic payment processor 20 | paddle_billing: 21 | email: paddle-billing@example.org 22 | first_name: Paddle Billing 23 | last_name: User 24 | 25 | # User with paddle classic payment processor 26 | paddle_classic: 27 | email: paddle-classic@example.org 28 | first_name: Paddle Classic 29 | last_name: User 30 | 31 | # User with fake_processor payment processor 32 | fake: 33 | email: fake@example.org 34 | first_name: Fake 35 | last_name: User 36 | 37 | # User with multiple payment processors 38 | multiple: 39 | email: multiple@example.org 40 | first_name: Multiple 41 | last_name: User 42 | 43 | deleted_customer: 44 | email: deleted@example.org 45 | first_name: Deleted 46 | last_name: User 47 | 48 | pending_customer: 49 | email: pending@example.org 50 | first_name: Pending 51 | last_name: User 52 | -------------------------------------------------------------------------------- /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_classic/webhooks/subscription_cancelled.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 3 | module Webhooks 4 | class SubscriptionCancelled 5 | def call(event) 6 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:paddle_classic, 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 | # Paddle subscriptions are canceled immediately, however we still want to give the user access to the end of the period they paid for 14 | ends_at = Time.zone.parse(event.cancellation_effective_date) 15 | pay_subscription.update!( 16 | status: (ends_at.future? ? :active : :canceled), 17 | trial_ends_at: (ends_at if pay_subscription.trial_ends_at?), 18 | ends_at: ends_at 19 | ) 20 | 21 | # Paddle classic doesn't allow reusing customers, so we should remove their payment methods 22 | Pay::PaymentMethod.where(customer_id: pay_subscription.customer_id).destroy_all 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_CLASSIC_VENDOR_ID"] } 11 | c.filter_sensitive_data("") { ENV["PADDLE_CLASSIC_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::PaddleClassic.vendor_auth_code } 15 | c.filter_sensitive_data("") { Pay::PaddleBilling.api_key } 16 | end 17 | 18 | class ActiveSupport::TestCase 19 | setup do 20 | # Test filenames are case sensitive in CI 21 | VCR.insert_cassette name, allow_unused_http_interactions: false, record_on_error: false 22 | end 23 | 24 | teardown do 25 | cassette = VCR.current_cassette 26 | VCR.eject_cassette 27 | rescue VCR::Errors::UnusedHTTPInteractionError 28 | puts 29 | puts "Unused HTTP requests in cassette: #{cassette.file}" 30 | raise 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/pay/paddle_classic/charge.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 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 = PaddleClassic.client.payments.list(subscription_id: customer.subscription.processor_id) 15 | charges = payments.data.select { |p| p[:id].to_s == processor_id } 16 | charges.try(:first) 17 | rescue ::Paddle::Error => e 18 | raise Pay::PaddleClassic::Error, e 19 | end 20 | 21 | def refund!(amount_to_refund) 22 | return unless customer.subscription 23 | payments = PaddleClassic.client.payments.list(subscription_id: customer.subscription.processor_id, is_paid: 1) 24 | if payments.total > 0 25 | PaddleClassic.client.payments.refund(order_id: payments.data.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 ::Paddle::Error => e 31 | raise Pay::PaddleClassic::Error, e 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/pay/paddle_billing/subscription_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleBilling::Subscription::Test < ActiveSupport::TestCase 4 | setup do 5 | @pay_customer = pay_customers(:paddle_billing) 6 | end 7 | 8 | test "paddle billing processor subscription" do 9 | assert_equal @pay_customer.subscription.processor_subscription.class, ::Paddle::Subscription 10 | assert_equal "active", @pay_customer.subscription.status 11 | end 12 | 13 | test "paddle billing paused subscription is not active" do 14 | @pay_customer.subscription.update!(status: :paused) 15 | refute @pay_customer.subscription.active? 16 | end 17 | 18 | test "paddle billing paused subscription is paused" do 19 | @pay_customer.subscription.update!(status: :paused) 20 | assert @pay_customer.subscription.paused? 21 | end 22 | 23 | test "paddle billing paused subscription is not canceled" do 24 | @pay_customer.subscription.update!(status: :paused) 25 | assert_not @pay_customer.subscription.canceled? 26 | end 27 | 28 | test "paddle billing can swap plans" do 29 | @pay_customer.subscription.swap("pri_01h7qfsc8apejhjgqqx50rghdz") 30 | assert_equal "pri_01h7qfsc8apejhjgqqx50rghdz", @pay_customer.subscription.processor_subscription.items.first.price.id 31 | assert_equal "active", @pay_customer.subscription.status 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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, :brand # Visa, Mastercard, Discover, PayPal 8 | store_accessor :data, :last4 9 | store_accessor :data, :exp_month 10 | store_accessor :data, :exp_year 11 | store_accessor :data, :email # PayPal, Stripe Link, etc 12 | store_accessor :data, :username 13 | store_accessor :data, :bank 14 | 15 | # Aliases to share PaymentMethodAttributes 16 | alias_attribute :payment_method_type, :type 17 | 18 | validates :processor_id, presence: true, uniqueness: {scope: :customer_id, case_sensitive: true} 19 | 20 | def self.find_by_processor_and_id(processor, processor_id) 21 | joins(:customer).find_by(processor_id: processor_id, pay_customers: {processor: processor}) 22 | end 23 | 24 | def self.pay_processor_for(name) 25 | "Pay::#{name.to_s.classify}::PaymentMethod".constantize 26 | end 27 | 28 | def payment_processor 29 | @payment_processor ||= self.class.pay_processor_for(customer.processor).new(self) 30 | end 31 | 32 | def make_default! 33 | return if default? 34 | 35 | payment_processor.make_default! 36 | 37 | customer.payment_methods.update_all(default: false) 38 | update!(default: true) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_billing" 21 | to_recursive_ostruct(event["data"]) 22 | when "paddle_classic" 23 | to_recursive_ostruct(event) 24 | when "stripe" 25 | ::Stripe::Event.construct_from(event) 26 | else 27 | event 28 | end 29 | end 30 | 31 | def to_recursive_ostruct(obj) 32 | if obj.is_a?(Hash) 33 | OpenStruct.new(obj.map { |key, val| [key, to_recursive_ostruct(val)] }.to_h) 34 | elsif obj.is_a?(Array) 35 | obj.map { |o| to_recursive_ostruct(o) } 36 | else # Assumed to be a primitive value 37 | obj 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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", "~> 10.0" 24 | gem "paddle", "~> 2.1", ">= 2.1.1" 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/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 | -------------------------------------------------------------------------------- /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/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_billing: 16 | owner: paddle_billing (User) 17 | processor: paddle_billing 18 | processor_id: ctm_0123 19 | default: true 20 | 21 | paddle_classic: 22 | owner: paddle_classic (User) 23 | processor: paddle_classic 24 | processor_id: 17368056 25 | default: true 26 | 27 | fake: 28 | owner: fake (User) 29 | processor: fake_processor 30 | processor_id: fake_1234 31 | default: true 32 | 33 | multiple_first: 34 | owner: multiple (User) 35 | processor: stripe 36 | processor_id: cus_1235 37 | default: true 38 | 39 | multiple_second: 40 | owner: multiple (User) 41 | processor: braintree 42 | processor_id: bt_1235 43 | default: true 44 | 45 | deleted: 46 | owner: deleted_customer (User) 47 | processor: stripe 48 | processor_id: 9999 49 | default: false 50 | deleted_at: <%= Time.current %> 51 | 52 | deleted2: 53 | owner: deleted_customer (User) 54 | processor: stripe 55 | processor_id: 9998 56 | default: false 57 | deleted_at: <%= Time.current %> 58 | 59 | pending_stripe: 60 | owner: pending (User) 61 | processor: stripe 62 | processor_id: 63 | default: true 64 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle_classic/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleClassic::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_classic}).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_classic_subscription_path(subscription) 19 | rescue Pay::Error => e 20 | flash[:alert] = e.message 21 | redirect_to new_paddle_classic_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_classic_subscription_path(@subscription) 33 | end 34 | 35 | def cancel 36 | @subscription.cancel 37 | redirect_to paddle_classic_subscription_path(@subscription) 38 | end 39 | 40 | def resume 41 | @subscription.resume 42 | redirect_to paddle_classic_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/support/fixtures/paddle_classic/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/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 | -------------------------------------------------------------------------------- /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 | 23 | test "stripe invoice credit balance is updated" do 24 | event = stripe_event("customer.updated") 25 | Pay::Stripe::Billable.any_instance.expects(:customer).returns(OpenStruct.new(invoice_credit_balance: Stripe::Util.convert_to_stripe_object(usd: 12345), invoice_settings: OpenStruct.new(default_payment_method: nil), currency: "usd")) 26 | Pay::Stripe::Webhooks::CustomerUpdated.new.call(event) 27 | @pay_customer.reload 28 | assert_equal "usd", @pay_customer.currency 29 | assert_equal 12345, @pay_customer.invoice_credit_balance["usd"] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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/paddle_classic/charge_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleClassic::Charge::Test < ActiveSupport::TestCase 4 | setup do 5 | @pay_customer = pay_customers(:paddle_classic) 6 | end 7 | 8 | test "paddle classic 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 classic 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 classic 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 | -------------------------------------------------------------------------------- /lib/pay/paddle_classic/webhooks/subscription_updated.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module PaddleClassic 3 | module Webhooks 4 | class SubscriptionUpdated 5 | def call(event) 6 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:paddle_classic, 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/pay/paddle_classic/billable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::PaddleClassic::Billable::Test < ActiveSupport::TestCase 4 | setup do 5 | @pay_customer = pay_customers(:paddle_classic) 6 | end 7 | 8 | test "paddle classic can create a charge" do 9 | charge = @pay_customer.charge(1000, {charge_name: "Test"}) 10 | assert_equal Pay::Charge, charge.class 11 | assert_equal 1000, charge.amount 12 | end 13 | 14 | test "paddle classic cannot create a charge without charge_name" do 15 | assert_raises(Pay::Error) { @pay_customer.charge(1000) } 16 | end 17 | 18 | test "retrieving a paddle classic subscription" do 19 | subscription = Pay::PaddleClassic.client.users.list(subscription_id: "3576390").data.try(:first) 20 | assert_equal @pay_customer.processor_subscription("3576390").subscription_id, subscription.subscription_id 21 | end 22 | 23 | test "paddle classic can sync payment information" do 24 | Pay::PaddleClassic::PaymentMethod.sync(pay_customer: @pay_customer) 25 | 26 | assert_equal "card", @pay_customer.default_payment_method.type 27 | assert_equal "Visa", @pay_customer.default_payment_method.brand 28 | assert_equal "0020", @pay_customer.default_payment_method.last4 29 | assert_equal "06", @pay_customer.default_payment_method.exp_month 30 | assert_equal "2022", @pay_customer.default_payment_method.exp_year 31 | end 32 | 33 | test "paddle classic can add payment method" do 34 | assert @pay_customer.add_payment_method 35 | end 36 | 37 | test "paddle classic can update payment method" do 38 | assert @pay_customer.update_payment_method(nil) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /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/controllers/paddle_billing/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | class PaddleBilling::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_billing}).order(created_at: :desc) 6 | end 7 | 8 | def show 9 | end 10 | 11 | def new 12 | @payment_processor = current_user.set_payment_processor :paddle_billing 13 | @payment_processor.customer unless @payment_processor.processor_id? 14 | end 15 | 16 | def create 17 | current_user.set_payment_processor params[:processor] 18 | current_user.payment_processor.payment_method_token = params[:card_token] 19 | subscription = current_user.payment_processor.subscribe(plan: params[:plan_id]) 20 | redirect_to paddle_billing_subscription_path(subscription) 21 | rescue Pay::Error => e 22 | flash[:alert] = e.message 23 | redirect_to new_paddle_billing_subscription_path 24 | end 25 | 26 | def edit 27 | end 28 | 29 | def update 30 | end 31 | 32 | def destroy 33 | @subscription.cancel_now! 34 | redirect_to paddle_billing_subscription_path(@subscription) 35 | end 36 | 37 | def cancel 38 | @subscription.cancel 39 | redirect_to paddle_billing_subscription_path(@subscription) 40 | end 41 | 42 | def resume 43 | @subscription.resume 44 | redirect_to paddle_billing_subscription_path(@subscription) 45 | end 46 | 47 | private 48 | 49 | def set_subscription 50 | @subscription = Pay::Subscription.find(params[:id]) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /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/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_billing do 22 | resource :payment_method, namespace: :paddle_billing 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 :paddle_classic do 37 | resource :payment_method, namespace: :paddle 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 | end 50 | 51 | namespace :stripe do 52 | resource :payment_method, namespace: :stripe 53 | resources :subscriptions do 54 | member do 55 | patch :cancel 56 | patch :resume 57 | end 58 | end 59 | resources :charges do 60 | member do 61 | patch :refund 62 | end 63 | end 64 | namespace :charges do 65 | resource :import 66 | end 67 | resource :checkout, namespace: :stripe 68 | end 69 | 70 | root to: "main#show" 71 | end 72 | -------------------------------------------------------------------------------- /test/pay/stripe/payment_method_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Stripe::PaymentMethodTest < ActiveSupport::TestCase 4 | setup do 5 | @pay_customer = pay_customers(:stripe) 6 | end 7 | 8 | test "Stripe sync returns Pay::PaymentMethod" do 9 | ::Stripe::Customer.stubs(:retrieve).returns(::Stripe::Customer.construct_from(invoice_settings: {default_payment_method: nil})) 10 | pay_payment_method = Pay::Stripe::PaymentMethod.sync("pm_123", object: fake_stripe_payment_method) 11 | assert pay_payment_method.is_a?(Pay::PaymentMethod) 12 | refute pay_payment_method.default? 13 | end 14 | 15 | test "Stripe sync sets default if payment method is default in invoice settings" do 16 | ::Stripe::Customer.stubs(:retrieve).returns(::Stripe::Customer.construct_from(invoice_settings: {default_payment_method: "pm_123"})) 17 | pay_payment_method = Pay::Stripe::PaymentMethod.sync("pm_123", object: fake_stripe_payment_method) 18 | assert pay_payment_method.default? 19 | end 20 | 21 | test "Stripe sync skips PaymentMethod without customer" do 22 | @pay_customer.update!(processor_id: nil) 23 | pay_payment_method = Pay::Stripe::PaymentMethod.sync("pm_123", object: fake_stripe_payment_method(customer: nil)) 24 | assert_nil pay_payment_method 25 | end 26 | 27 | test "Stripe sync Link payment method" do 28 | object = ::Stripe::PaymentMethod.construct_from json_fixture("stripe/payment_methods/link") 29 | attributes = Pay::Stripe::PaymentMethod.extract_attributes(object) 30 | assert_equal "link", attributes[:payment_method_type] 31 | assert_equal "customer@example.org", attributes[:email] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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 | old_env = ENV.to_hash 6 | ENV.update( 7 | "STRIPE_PUBLIC_KEY" => "public", 8 | "STRIPE_PRIVATE_KEY" => "private", 9 | "STRIPE_SIGNING_SECRET" => "secret" 10 | ) 11 | 12 | assert_equal "public", Pay::Stripe.public_key 13 | assert_equal "private", Pay::Stripe.private_key 14 | assert_equal "secret", Pay::Stripe.signing_secret 15 | ensure 16 | ENV.update(old_env) 17 | end 18 | 19 | test "can generate a client_reference_id for a model" do 20 | user = users(:none) 21 | assert_equal "User_#{user.id}", Pay::Stripe.to_client_reference_id(user) 22 | end 23 | 24 | test "raises an error for client_reference_id if the object does not use Pay" do 25 | assert_raises ArgumentError do 26 | Pay::Stripe.to_client_reference_id("not-a-user-instance") 27 | end 28 | end 29 | 30 | test "can find a record by client_reference_id" do 31 | user = users(:none) 32 | assert_equal user, Pay::Stripe.find_by_client_reference_id("User_#{user.id}") 33 | end 34 | 35 | test "returns nil if record not found by client_reference_id" do 36 | assert_nil Pay::Stripe.find_by_client_reference_id("User_9999") 37 | end 38 | 39 | test "returns nil if client_reference_id is not an allowed class" do 40 | assert_nil Pay::Stripe.find_by_client_reference_id("Secret::Agent::Man_9999") 41 | end 42 | 43 | test "env ignores Stripe credentials when not defined" do 44 | Rails.stub(:application, nil) do 45 | assert_nil Pay::Stripe.send(:credentials) 46 | end 47 | end 48 | end 49 | --------------------------------------------------------------------------------