├── test ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── javascripts │ │ │ │ ├── channels │ │ │ │ │ └── .keep │ │ │ │ ├── cable.js │ │ │ │ └── application.js │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── user.rb │ │ │ ├── application_record.rb │ │ │ └── team.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── main_controller.rb │ │ │ ├── payment_methods_controller.rb │ │ │ ├── application_controller.rb │ │ │ ├── paddle │ │ │ │ ├── payment_methods_controller.rb │ │ │ │ ├── charges_controller.rb │ │ │ │ └── subscriptions_controller.rb │ │ │ ├── braintree │ │ │ │ ├── payment_methods_controller.rb │ │ │ │ ├── charges_controller.rb │ │ │ │ └── subscriptions_controller.rb │ │ │ └── stripe │ │ │ │ ├── payment_methods_controller.rb │ │ │ │ ├── checkouts_controller.rb │ │ │ │ ├── charges │ │ │ │ └── imports_controller.rb │ │ │ │ ├── charges_controller.rb │ │ │ │ └── subscriptions_controller.rb │ │ ├── views │ │ │ ├── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ └── mailer.html.erb │ │ │ ├── paddle │ │ │ │ ├── charges │ │ │ │ │ ├── show.html.erb │ │ │ │ │ ├── index.html.erb │ │ │ │ │ └── new.html.erb │ │ │ │ ├── payment_methods │ │ │ │ │ └── edit.html.erb │ │ │ │ └── subscriptions │ │ │ │ │ ├── index.html.erb │ │ │ │ │ ├── show.html.erb │ │ │ │ │ └── new.html.erb │ │ │ ├── stripe │ │ │ │ ├── charges │ │ │ │ │ ├── show.html.erb │ │ │ │ │ ├── imports │ │ │ │ │ │ └── new.html.erb │ │ │ │ │ ├── index.html.erb │ │ │ │ │ └── new.html.erb │ │ │ │ ├── subscriptions │ │ │ │ │ ├── index.html.erb │ │ │ │ │ ├── show.html.erb │ │ │ │ │ └── new.html.erb │ │ │ │ ├── checkouts │ │ │ │ │ └── show.html.erb │ │ │ │ └── payment_methods │ │ │ │ │ └── edit.html.erb │ │ │ ├── braintree │ │ │ │ ├── charges │ │ │ │ │ ├── show.html.erb │ │ │ │ │ ├── index.html.erb │ │ │ │ │ └── new.html.erb │ │ │ │ ├── subscriptions │ │ │ │ │ ├── index.html.erb │ │ │ │ │ ├── show.html.erb │ │ │ │ │ └── new.html.erb │ │ │ │ └── payment_methods │ │ │ │ │ └── edit.html.erb │ │ │ ├── main │ │ │ │ └── show.html.erb │ │ │ └── payment_methods │ │ │ │ └── show.html.erb │ │ ├── helpers │ │ │ ├── application_helper.rb │ │ │ └── current_helper.rb │ │ ├── jobs │ │ │ └── application_job.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ └── javascript │ │ │ ├── processors │ │ │ └── paddle.js │ │ │ ├── controllers │ │ │ ├── index.js │ │ │ └── braintree_controller.js │ │ │ └── packs │ │ │ └── application.js │ ├── .browserslistrc │ ├── config │ │ ├── database.yml │ │ ├── webpack │ │ │ ├── environment.js │ │ │ ├── test.js │ │ │ ├── production.js │ │ │ └── development.js │ │ ├── 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 │ │ │ ├── pay.rb │ │ │ ├── assets.rb │ │ │ ├── wrap_parameters.rb │ │ │ └── inflections.rb │ │ ├── boot.rb │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── secrets.yml │ │ ├── routes.rb │ │ ├── environments │ │ │ ├── test.rb │ │ │ └── development.rb │ │ ├── puma.rb │ │ └── webpacker.yml │ ├── bin │ │ ├── rake │ │ ├── rails │ │ ├── webpack │ │ ├── webpack-dev-server │ │ └── setup │ ├── config.ru │ ├── Rakefile │ ├── package.json │ ├── postcss.config.js │ ├── db │ │ └── migrate │ │ │ ├── 20170205000000_create_users.rb │ │ │ ├── 20200603150703_add_pay_billable_to_users.rb │ │ │ └── 20200603152357_add_pay_billable_to_teams.rb │ └── babel.config.js ├── support │ └── fixtures │ │ ├── stripe │ │ ├── charge_refunded_event.json │ │ ├── charge_succeeded_event.json │ │ ├── customer_deleted_event.json │ │ ├── customer_updated_event.json │ │ └── payment_method.updated.json │ │ └── paddle │ │ ├── verification │ │ └── paddle_public_key.pem │ │ ├── subscription_cancelled.json │ │ ├── subscription_created.json │ │ ├── subscription_payment_refunded.json │ │ ├── subscription_updated.json │ │ └── subscription_payment_succeeded.json ├── pay │ ├── paddle │ │ ├── webhooks │ │ │ ├── signature_verifier_test.rb │ │ │ ├── subscription_payment_refunded_test.rb │ │ │ └── subscription_created_test.rb │ │ ├── error_test.rb │ │ ├── charge_test.rb │ │ └── billable_test.rb │ ├── stripe_test.rb │ ├── stripe │ │ ├── error_test.rb │ │ ├── billable_test.rb │ │ ├── webhooks │ │ │ ├── payment_action_required_test.rb │ │ │ ├── subscription_renewing_test.rb │ │ │ ├── charge_refunded_test.rb │ │ │ ├── customer_deleted_test.rb │ │ │ ├── customer_updated_test.rb │ │ │ ├── payment_method_updated_test.rb │ │ │ └── charge_succeeded_test.rb │ │ └── checkout_test.rb │ ├── braintree │ │ ├── webhooks │ │ │ ├── subscription_trial_ended.rb │ │ │ ├── subscription_canceled_test.rb │ │ │ └── subscription_charged_successfully.rb │ │ ├── error_test.rb │ │ └── charge_test.rb │ ├── fake_processor │ │ ├── charge_test.rb │ │ ├── subscription_test.rb │ │ └── billable_test.rb │ ├── billable │ │ └── sync_email_test.rb │ ├── chargeable_test.rb │ └── webhooks │ │ └── delegator_test.rb ├── routes │ └── webhooks_test.rb ├── controllers │ └── pay │ │ └── webhooks │ │ ├── paddle_controller_test.rb │ │ └── braintree_controller_test.rb ├── jobs │ └── pay │ │ └── email_sync_job_test.rb ├── pay_test.rb ├── vcr_cassettes │ └── test_user_with_braintree_as_processor.yml └── mailers │ └── pay │ └── user_mailer_test.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 │ │ └── email_sync_job.rb ├── controllers │ └── pay │ │ ├── application_controller.rb │ │ ├── payments_controller.rb │ │ └── webhooks │ │ ├── braintree_controller.rb │ │ ├── paddle_controller.rb │ │ └── stripe_controller.rb ├── mailers │ └── pay │ │ ├── application_mailer.rb │ │ └── user_mailer.rb ├── models │ └── pay │ │ ├── application_record.rb │ │ └── charge.rb └── views │ ├── pay │ ├── user_mailer │ │ ├── subscription_renewing.html.erb │ │ ├── payment_action_required.html.erb │ │ ├── receipt.html.erb │ │ └── refund.html.erb │ └── stripe │ │ └── _checkout_button.html.erb │ └── layouts │ └── pay │ └── application.html.erb ├── docs ├── braintree.md ├── logo.png ├── paddle.md ├── webhooks.md ├── fake_processor.md ├── adding_a_payment_processor.md └── stripe.md ├── lib ├── pay │ ├── version.rb │ ├── fake_processor │ │ ├── error.rb │ │ ├── charge.rb │ │ ├── subscription.rb │ │ └── billable.rb │ ├── paddle │ │ ├── error.rb │ │ ├── webhooks │ │ │ ├── subscription_payment_refunded.rb │ │ │ ├── subscription_cancelled.rb │ │ │ ├── subscription_updated.rb │ │ │ └── subscription_payment_succeeded.rb │ │ └── charge.rb │ ├── stripe │ │ ├── error.rb │ │ ├── webhooks │ │ │ ├── customer_updated.rb │ │ │ ├── payment_method_updated.rb │ │ │ ├── charge_refunded.rb │ │ │ ├── subscription_deleted.rb │ │ │ ├── charge_succeeded.rb │ │ │ ├── customer_deleted.rb │ │ │ ├── subscription_renewing.rb │ │ │ ├── payment_intent_succeeded.rb │ │ │ ├── payment_action_required.rb │ │ │ ├── subscription_updated.rb │ │ │ └── subscription_created.rb │ │ └── charge.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 │ │ └── charge.rb │ ├── fake_processor.rb │ ├── webhooks.rb │ ├── errors.rb │ ├── engine.rb │ ├── env.rb │ ├── receipts.rb │ ├── payment.rb │ ├── billable │ │ └── sync_email.rb │ └── webhooks │ │ └── delegator.rb └── generators │ ├── active_record │ ├── templates │ │ └── migration.rb │ └── pay_generator.rb │ └── pay │ ├── views_generator.rb │ ├── email_views_generator.rb │ ├── pay_generator.rb │ └── orm_helpers.rb ├── .standard.yml ├── Appraisals ├── .gitignore ├── config ├── routes.rb └── locales │ └── en.yml ├── .rubocop.yml ├── gemfiles ├── rails_6.gemfile └── rails_6_1.gemfile ├── db └── migrate │ ├── 20190816015720_add_status_to_pay_subscriptions.rb │ ├── 20170727235816_create_pay_charges.rb │ ├── 20170205020145_create_pay_subscriptions.rb │ └── 20200603134434_add_data_to_pay_models.rb ├── bin └── rails ├── .github └── FUNDING.yml ├── Rakefile ├── pay.gemspec ├── Gemfile └── MIT-LICENSE /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/pay/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/braintree.md: -------------------------------------------------------------------------------- 1 | # Using Pay with Braintree 2 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /lib/pay/version.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | VERSION = "2.6.7" 3 | end 4 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/pay/HEAD/docs/logo.png -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/test.sqlite3 4 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - 'test/dummy/**/*' 3 | - 'lib/generators/active_record/templates/*' 4 | -------------------------------------------------------------------------------- /app/helpers/pay/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | include Pay::Billable 3 | end 4 | -------------------------------------------------------------------------------- /app/jobs/pay/application_job.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class ApplicationJob < ActiveJob::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/assets/config/pay_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../javascripts/pay .js 2 | //= link_directory ../stylesheets/pay .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/main_controller.rb: -------------------------------------------------------------------------------- 1 | class MainController < ApplicationController 2 | def show 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /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/config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | 3 | module.exports = environment 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-6" do 2 | gem "rails", "~> 6.0.0" 3 | end 4 | 5 | appraise "rails-6-1" do 6 | gem "rails", "~> 6.1.0" 7 | end 8 | -------------------------------------------------------------------------------- /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/controllers/payment_methods_controller.rb: -------------------------------------------------------------------------------- 1 | class PaymentMethodsController < ApplicationController 2 | def show 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/pay/paddle/error.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 3 | class Error < Pay::Error 4 | delegate :message, to: :cause 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/pay/stripe/error.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | class Error < Pay::Error 4 | delegate :message, to: :cause 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/mailers/pay/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class ApplicationMailer < ActionMailer::Base 3 | default from: Pay.support_email 4 | layout "mailer" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | //= link pay_manifest.js 6 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/processors/paddle.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("turbolinks:load", () => { 2 | Paddle.Environment.set('sandbox'); 3 | Paddle.Setup({ vendor: 924 }); 4 | }) 5 | -------------------------------------------------------------------------------- /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/dummy/config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/team.rb: -------------------------------------------------------------------------------- 1 | class Team < ApplicationRecord 2 | include Pay::Billable 3 | 4 | belongs_to :owner, class_name: "User" 5 | 6 | def email 7 | owner.email 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /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/config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /test/support/fixtures/stripe/charge_refunded_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "id": "ch_chargeid", 4 | "amount_refunded": 500, 5 | "created": 1546324243, 6 | "customer": "cus_customerid" 7 | } 8 | } -------------------------------------------------------------------------------- /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("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 | <%= link_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 | <%= link_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 | <%= link_to "Refund", refund_braintree_charge_path(@charge), method: :patch, class: "d-block" %> 7 | -------------------------------------------------------------------------------- /test/dummy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@rails/ujs": "^6.0.0", 4 | "@rails/webpacker": "5.2.1", 5 | "stimulus": "^1.1.1", 6 | "turbolinks": "^5.2.0" 7 | }, 8 | "devDependencies": { 9 | "webpack-dev-server": "^3.11.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | ENV["RAILS_ENV"] ||= "test" 4 | 5 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 6 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 7 | -------------------------------------------------------------------------------- /test/dummy/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 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 :Subscription, "pay/fake_processor/subscription" 6 | autoload :Error, "pay/fake_processor/error" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/controllers/pay/payments_controller.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class PaymentsController < ApplicationController 3 | layout "pay/application" 4 | 5 | def show 6 | @redirect_to = params[:back].presence || root_path 7 | @payment = Payment.from_id(params[:id]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/generators/active_record/templates/migration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPayBillableTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %> 4 | def change 5 | change_table :<%= table_name %> do |t| 6 | <%= migration_data -%> 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/pay/webhooks.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Webhooks 3 | autoload :Delegator, "pay/webhooks/delegator" 4 | 5 | class << self 6 | delegate :configure, :instrument, to: :delegator 7 | 8 | def delegator 9 | @delegator ||= Delegator.new 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/log/*.log 7 | test/dummy/tmp/ 8 | test/dummy/public/packs/ 9 | test/dummy/public/packs-test/ 10 | test/dummy/node_modules/ 11 | .DS_Store 12 | .byebug_history 13 | 14 | # Releases 15 | pay-*.gem 16 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Pay::Engine.routes.draw do 4 | resources :payments, only: [:show], module: :pay 5 | post "webhooks/stripe", to: "pay/webhooks/stripe#create" 6 | post "webhooks/braintree", to: "pay/webhooks/braintree#create" 7 | post "webhooks/paddle", to: "pay/webhooks/paddle#create" 8 | end 9 | -------------------------------------------------------------------------------- /app/models/pay/application_record.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class ApplicationRecord < Pay.model_parent_class.constantize 3 | self.abstract_class = true 4 | 5 | def self.json_column?(name) 6 | return unless connected? && table_exists? 7 | [:json, :jsonb].include?(attribute_types[name].type) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle/payment_methods_controller.rb: -------------------------------------------------------------------------------- 1 | class Paddle::PaymentMethodsController < ApplicationController 2 | def edit 3 | end 4 | 5 | def update 6 | current_user.processor = params[:processor] 7 | current_user.update_card(params[:card_token]) 8 | redirect_to payment_method_path 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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.processor = params[:processor] 7 | current_user.update_card(params[:card_token]) 8 | redirect_to payment_method_path 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/jobs/pay/email_sync_job.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class EmailSyncJob < ApplicationJob 3 | queue_as :default 4 | 5 | def perform(id, class_name) 6 | billable = class_name.constantize.find(id) 7 | billable.sync_email_with_processor 8 | rescue ActiveRecord::RecordNotFound 9 | Rails.logger.info "Couldn't find a #{class_name} with ID = #{id}" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/fixtures/stripe/charge_succeeded_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "id": "ch_chargeid", 4 | "amount": 500, 5 | "created": 1546332337, 6 | "customer": "cus_customerid", 7 | "payment_method_details": { 8 | "card": { 9 | "brand": "Visa", 10 | "exp_month": 1, 11 | "exp_year": 2019, 12 | "last4": "4444" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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/dummy/db/migrate/20170205000000_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :users do |t| 4 | t.string :email 5 | t.string :first_name 6 | t.string :last_name 7 | end 8 | 9 | create_table :teams do |t| 10 | t.string :email 11 | t.string :name 12 | t.references :owner, polymorphic: true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /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.processor = params[:processor] 9 | current_user.update_card(params[:card_token]) 10 | redirect_to payment_method_path 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /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/subscription_renewing.html.erb: -------------------------------------------------------------------------------- 1 |

Your upcoming subscription renewal

2 |

This is just a friendly reminder that your <%= Pay.business_name %> subscription will renew automatically on <%= l params[:date].to_date, format: :long %>.

3 | 4 |

You may manage your subscription via your account. If you have any questions, please hit reply and let us know.

5 | 6 |

- The <%= Pay.business_name %> Team

7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/paddle.md: -------------------------------------------------------------------------------- 1 | # Using Pay with Paddle 2 | 3 | ## Paddle Sandbox 4 | 5 | The [Paddle Sandbox](https://developer.paddle.com/getting-started/sandbox) can be used for testing your Paddle integration. 6 | 7 | ```html 8 | 9 | 13 | ``` 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the rails generate channel command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /app/views/pay/user_mailer/payment_action_required.html.erb: -------------------------------------------------------------------------------- 1 |

Extra confirmation is needed to process your payment

2 |

Your <%= Pay.business_name %> subscription requires confirmation to process your payment to continue access.

3 | 4 |

<%= link_to "Click here to confirm your payment", pay.payment_url(params[:payment_intent_id]) %>. If you have any questions, please hit reply and let us know.

5 | 6 |

- The <%= Pay.business_name %> Team

7 | -------------------------------------------------------------------------------- /test/pay/paddle/webhooks/signature_verifier_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::Webhooks::SubscriptionCreatedTest < ActiveSupport::TestCase 4 | setup do 5 | @data = JSON.parse(File.read("test/support/fixtures/paddle/subscription_created.json")) 6 | end 7 | 8 | test "webhook signature is verified correctly" do 9 | verifier = Pay::Paddle::Webhooks::SignatureVerifier.new(@data) 10 | assert verifier.verify 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Load all the controllers within this directory and all subdirectories. 2 | // Controller files must be named *_controller.js. 3 | 4 | import { Application } from "stimulus" 5 | import { definitionsFromContext } from "stimulus/webpack-helpers" 6 | 7 | const application = Application.start() 8 | const context = require.context("controllers", true, /_controller\.js$/) 9 | application.load(definitionsFromContext(context)) 10 | -------------------------------------------------------------------------------- /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/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/stripe_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Stripe::Test < ActiveSupport::TestCase 4 | test "finds API keys from env" do 5 | ENV["STRIPE_PUBLIC_KEY"] = "public" 6 | ENV["STRIPE_PRIVATE_KEY"] = "private" 7 | ENV["STRIPE_SIGNING_SECRET"] = "secret" 8 | 9 | assert_equal "public", Pay::Stripe.public_key 10 | assert_equal "private", Pay::Stripe.private_key 11 | assert_equal "secret", Pay::Stripe.signing_secret 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/generators/pay/pay_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/named_base" 4 | 5 | module Pay 6 | module Generators 7 | class PayGenerator < Rails::Generators::NamedBase 8 | include Rails::Generators::ResourceHelpers 9 | 10 | namespace "pay" 11 | source_root File.expand_path("../templates", __FILE__) 12 | 13 | desc "Generates a migration to add Billable fields to a model." 14 | 15 | hook_for :orm 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /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/db/migrate/20200603150703_add_pay_billable_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPayBillableToUsers < ActiveRecord::Migration[6.0] 4 | def change 5 | change_table :users do |t| 6 | t.string :processor 7 | t.string :processor_id 8 | t.datetime :trial_ends_at 9 | t.string :card_type 10 | t.string :card_last4 11 | t.string :card_exp_month 12 | t.string :card_exp_year 13 | t.text :extra_billing_info 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20200603152357_add_pay_billable_to_teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPayBillableToTeams < ActiveRecord::Migration[6.0] 4 | def change 5 | change_table :teams do |t| 6 | t.string :processor 7 | t.string :processor_id 8 | t.datetime :trial_ends_at 9 | t.string :card_type 10 | t.string :card_last4 11 | t.string :card_exp_month 12 | t.string :card_exp_year 13 | t.text :extra_billing_info 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /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/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 | billable = Pay.find_billable(processor: :stripe, processor_id: object.id) 8 | 9 | # Couldn't find user, we can skip 10 | return unless billable.present? 11 | 12 | Pay::Stripe::Billable.new(billable).sync_card_from_stripe 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/pay/stripe/error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Stripe::ErrorTest < ActiveSupport::TestCase 4 | setup do 5 | @user = User.create!(email: "gob@bluth.com", processor: :stripe) 6 | end 7 | 8 | test "re-raised stripe exceptions keep the same message" do 9 | exception = assert_raises(Pay::Stripe::Error) { @user.charge(0) } 10 | assert_equal "This value must be greater than or equal to 1.", exception.message 11 | assert_equal ::Stripe::InvalidRequestError, exception.cause.class 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /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 | billable = Pay.find_billable(processor: :stripe, processor_id: object.customer) 8 | 9 | # Couldn't find user, we can skip 10 | return unless billable.present? 11 | 12 | Pay::Stripe::Billable.new(billable).sync_card_from_stripe 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/app/views/stripe/subscriptions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Stripe Subscription

2 | 3 |
<%= @subscription.pretty_inspect %>
4 | 5 |
Actions
6 | <%= link_to "Resume", resume_stripe_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period?%> 7 | <%= link_to "Cancel at period end", cancel_stripe_subscription_path(@subscription), method: :patch, class: "d-block" %> 8 | <%= link_to "Cancel immediately", stripe_subscription_path(@subscription), method: :delete, class: "d-block" %> 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "byebug" 6 | gem "appraisal", branch: "fix-bundle-env", git: "https://github.com/excid3/appraisal.git" 7 | gem "braintree", ">= 2.92.0", "< 4.0" 8 | gem "stripe", ">= 2.8" 9 | gem "paddle_pay", "~> 0.1.0" 10 | gem "sqlite3", "~> 1.4" 11 | gem "mysql2" 12 | gem "pg" 13 | gem "puma" 14 | gem "standard" 15 | gem "turbolinks" 16 | gem "web-console", group: :development 17 | gem "webpacker" 18 | gem "rails", "~> 6.0.0" 19 | 20 | gemspec path: "../" 21 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle/subscriptions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Braintree Subscription

2 | 3 |
<%= @subscription.pretty_inspect %>
4 | 5 |
Actions
6 | <%= link_to "Resume", resume_braintree_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period?%> 7 | <%= link_to "Cancel at period end", cancel_braintree_subscription_path(@subscription), method: :patch, class: "d-block" %> 8 | <%= link_to "Cancel immediately", braintree_subscription_path(@subscription), method: :delete, class: "d-block" %> 9 | -------------------------------------------------------------------------------- /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 = "Business Name" 9 | config.business_address = "1600 Pennsylvania Avenue NW" 10 | config.application_name = "My App" 11 | config.support_email = "helpme@example.com" 12 | 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20190816015720_add_status_to_pay_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddStatusToPaySubscriptions < ActiveRecord::Migration[4.2] 2 | def self.up 3 | add_column :pay_subscriptions, :status, :string 4 | 5 | # Any existing subscriptions should be marked as 'active' 6 | # This won't actually make them active if their ends_at column is expired 7 | Pay::Subscription.reset_column_information 8 | Pay::Subscription.update_all(status: :active) 9 | end 10 | 11 | def self.down 12 | remove_column :pay_subscriptions, :status 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /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", branch: "fix-bundle-env", git: "https://github.com/excid3/appraisal.git" 7 | gem "braintree", ">= 2.92.0", "< 4.0" 8 | gem "stripe", ">= 2.8" 9 | gem "paddle_pay", "~> 0.1.0" 10 | gem "sqlite3", "~> 1.4" 11 | gem "mysql2" 12 | gem "pg" 13 | gem "puma" 14 | gem "standard" 15 | gem "turbolinks" 16 | gem "web-console", group: :development 17 | gem "webpacker" 18 | gem "rails", "~> 6.1.0" 19 | 20 | gemspec path: "../" 21 | -------------------------------------------------------------------------------- /test/dummy/app/views/braintree/subscriptions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Braintree Subscription

2 | 3 |
<%= @subscription.pretty_inspect %>
4 | 5 |
Actions
6 | <%= link_to "Resume", resume_braintree_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period?%> 7 | <%= link_to "Cancel at period end", cancel_braintree_subscription_path(@subscription), method: :patch, class: "d-block" %> 8 | <%= link_to "Cancel immediately", braintree_subscription_path(@subscription), method: :delete, class: "d-block" %> 9 | -------------------------------------------------------------------------------- /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" 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 | -------------------------------------------------------------------------------- /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("errors.action_required") 17 | end 18 | end 19 | 20 | class InvalidPaymentMethod < PaymentError 21 | def message 22 | I18n.t("errors.invalid_payment") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/pay/braintree/webhooks/subscription_trial_ended.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Braintree 3 | module Webhooks 4 | class SubscriptionTrialEnded 5 | def call(event) 6 | subscription = event.subscription 7 | return if subscription.nil? 8 | 9 | pay_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 10 | return unless pay_subscription.present? 11 | 12 | pay_subscription.update!(trial_ends_at: Time.current) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /test/dummy/app/views/paddle/subscriptions/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Subscribe 4 | 5 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /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 | ENGINE_ROOT = File.expand_path('..', __dir__) 6 | ENGINE_PATH = File.expand_path('../lib/pay/engine', __dir__) 7 | APP_PATH = File.expand_path('../test/dummy/config/application', __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 12 | 13 | require 'rails/all' 14 | require 'rails/engine/commands' 15 | -------------------------------------------------------------------------------- /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/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | Bundler.require(*Rails.groups) 6 | require "pay" 7 | require "pp" 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | # Settings in config/environments/* take precedence over those specified here. 12 | # Application configuration should go into files in config/initializers 13 | # -- all .rb files in that directory are automatically loaded. 14 | 15 | config.active_job.queue_adapter = :test 16 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /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_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | pay_subscription.update!(ends_at: Time.current, status: :canceled) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | pay_subscription.update(trial_ends_at: Time.zone.now) 15 | end 16 | end 17 | end 18 | end 19 | end 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 | -------------------------------------------------------------------------------- /db/migrate/20170727235816_create_pay_charges.rb: -------------------------------------------------------------------------------- 1 | class CreatePayCharges < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :pay_charges do |t| 4 | # Some Billable objects use string as ID, add `type: :string` if needed 5 | t.references :owner, polymorphic: true 6 | t.string :processor, null: false 7 | t.string :processor_id, null: false 8 | t.integer :amount, null: false 9 | t.integer :amount_refunded 10 | t.string :card_type 11 | t.string :card_last4 12 | t.string :card_exp_month 13 | t.string :card_exp_year 14 | 15 | t.timestamps 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/pay/fake_processor/charge_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::FakeProcessor::Charge::Test < ActiveSupport::TestCase 4 | setup do 5 | @billable = User.create!(email: "gob@bluth.com", processor: :fake_processor, processor_id: "17368056", pay_fake_processor_allowed: true) 6 | @charge = @billable.charge(10_00) 7 | end 8 | 9 | test "fake processor charge" do 10 | assert_equal @charge, @charge.processor_charge 11 | end 12 | 13 | test "fake processor refund" do 14 | assert_nil @charge.amount_refunded 15 | @charge.refund! 16 | assert_equal 10_00, @charge.reload.amount_refunded 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/routes/webhooks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class WebhookRoutesTest < ActionDispatch::IntegrationTest 6 | test "stripe webhook routes get mounted correctly" do 7 | post "/pay/webhooks/stripe", as: :json 8 | assert_response :bad_request 9 | end 10 | 11 | test "braintree webhook routes get mounted correctly" do 12 | post "/pay/webhooks/braintree", as: :json 13 | assert_response :bad_request 14 | end 15 | 16 | test "paddle webhook routes get mounted correctly" do 17 | post "/pay/webhooks/paddle", as: :json 18 | assert_response :bad_request 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/pay/paddle/error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::ErrorTest < ActiveSupport::TestCase 4 | setup do 5 | @user = User.create!(email: "gob@bluth.com", processor: :stripe) 6 | end 7 | 8 | test "re-raised paddle exceptions keep the same message" do 9 | exception = assert_raises { 10 | begin 11 | raise ::PaddlePay::ConnectionError, "The connection failed" 12 | rescue 13 | raise ::Pay::Paddle::Error 14 | end 15 | } 16 | 17 | assert_equal "The connection failed", exception.message 18 | assert_equal ::PaddlePay::ConnectionError, exception.cause.class 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20170205020145_create_pay_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class CreatePaySubscriptions < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :pay_subscriptions do |t| 4 | # Some Billable objects use string as ID, add `type: :string` if needed 5 | t.references :owner, polymorphic: true 6 | t.string :name, null: false 7 | t.string :processor, null: false 8 | t.string :processor_id, null: false 9 | t.string :processor_plan, null: false 10 | t.integer :quantity, default: 1, null: false 11 | t.datetime :trial_ends_at 12 | t.datetime :ends_at 13 | 14 | t.timestamps 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20200603134434_add_data_to_pay_models.rb: -------------------------------------------------------------------------------- 1 | class AddDataToPayModels < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :pay_subscriptions, :data, data_column_type 4 | add_column :pay_charges, :data, data_column_type 5 | end 6 | 7 | def data_column_type 8 | default_hash = ActiveRecord::Base.configurations.default_hash 9 | 10 | # Rails 6.1 uses a symbol key instead of a string 11 | adapter = default_hash.dig(:adapter) || default_hash.dig("adapter") 12 | 13 | case adapter 14 | when "mysql2" 15 | :json 16 | when "postgresql" 17 | :jsonb 18 | else 19 | :text 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/pay/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pay 4 | class Engine < ::Rails::Engine 5 | engine_name "pay" 6 | 7 | initializer "pay.processors" do |app| 8 | if Pay.automount_routes 9 | app.routes.append do 10 | mount Pay::Engine, at: Pay.routes_path, as: "pay" 11 | end 12 | end 13 | end 14 | 15 | config.to_prepare do 16 | Pay::Stripe.setup if defined? ::Stripe 17 | Pay::Braintree.setup if defined? ::Braintree 18 | Pay::Paddle.setup if defined? ::PaddlePay 19 | 20 | Pay.charge_model.include Pay::Receipts if defined? ::Receipts::Receipt 21 | end 22 | end 23 | end 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_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | pay_subscription.update!(ends_at: Time.current, status: :canceled) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/stripe/checkouts_controller.rb: -------------------------------------------------------------------------------- 1 | module Stripe 2 | class CheckoutsController < ApplicationController 3 | def show 4 | current_user.processor = :stripe 5 | current_user.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/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/stripe/webhooks/charge_refunded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class ChargeRefunded 5 | def call(event) 6 | object = event.data.object 7 | charge = Pay.charge_model.find_by(processor: :stripe, processor_id: object.id) 8 | 9 | return unless charge.present? 10 | 11 | charge.update(amount_refunded: object.amount_refunded) 12 | notify_user(charge.owner, charge) 13 | end 14 | 15 | def notify_user(billable, charge) 16 | if Pay.send_emails 17 | Pay::UserMailer.with(billable: billable, charge: charge).refund.deliver_later 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/pay/billable/sync_email_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Billable::SyncEmail::Test < ActiveSupport::TestCase 4 | include ActiveJob::TestHelper 5 | 6 | test "email sync only on updating customer email" do 7 | billable = User.new(email: "test@example.com", processor_id: "test") 8 | 9 | assert_no_enqueued_jobs do 10 | billable.save 11 | end 12 | 13 | assert_enqueued_with(job: Pay::EmailSyncJob, args: [billable.id, "User"]) do 14 | billable.update(email: "test@test.com") 15 | end 16 | end 17 | 18 | test "email sync should be ignored for billable that delegates email" do 19 | assert_no_enqueued_jobs do 20 | Team.create(name: "Team 1") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | // This file is automatically compiled by Webpack, along with any other files 3 | // present in this directory. You're encouraged to place your actual application logic in 4 | // a relevant structure within app/javascript and only use these pack files to reference 5 | // that code so it'll be compiled. 6 | // 7 | // To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate 8 | // layout file, like app/views/layouts/application.html.erb 9 | 10 | window.Rails = require("@rails/ujs") 11 | require("turbolinks").start() 12 | require("processors/stripe") 13 | require("processors/paddle") 14 | 15 | Rails.start() 16 | 17 | import "controllers" 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/pay/paddle/webhooks/subscription_payment_refunded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 3 | module Webhooks 4 | class SubscriptionPaymentRefunded 5 | def call(event) 6 | charge = Pay.charge_model.find_by(processor: :paddle, processor_id: event["subscription_payment_id"]) 7 | return unless charge.present? 8 | 9 | charge.update(amount_refunded: Integer(event["gross_refund"].to_f * 100)) 10 | notify_user(charge.owner, charge) 11 | end 12 | 13 | def notify_user(billable, charge) 14 | if Pay.send_emails 15 | Pay::UserMailer.with(billable: billable, charge: charge).refund.deliver_later 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/pay/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/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/charge.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 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 | ::Stripe::Charge.retrieve(processor_id) 14 | rescue ::Stripe::StripeError => e 15 | raise Pay::Stripe::Error, e 16 | end 17 | 18 | def refund!(amount_to_refund) 19 | ::Stripe::Refund.create(charge: processor_id, amount: amount_to_refund) 20 | pay_charge.update(amount_refunded: amount_to_refund) 21 | rescue ::Stripe::StripeError => e 22 | raise Pay::Stripe::Error, e 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/mailers/pay/user_mailer.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class UserMailer < ApplicationMailer 3 | def receipt 4 | if params[:charge].respond_to? :receipt 5 | attachments[params[:charge].filename] = params[:charge].receipt 6 | end 7 | 8 | mail to: to 9 | end 10 | 11 | def refund 12 | mail to: to 13 | end 14 | 15 | def subscription_renewing 16 | mail to: to 17 | end 18 | 19 | def payment_action_required 20 | mail to: to 21 | end 22 | 23 | private 24 | 25 | def to 26 | if params[:billable].respond_to?(:customer_name) 27 | "#{params[:billable].customer_name} <#{params[:billable].email}>" 28 | else 29 | params[:billable].email 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/pay/stripe/webhooks/subscription_deleted.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class SubscriptionDeleted 5 | def call(event) 6 | object = event.data.object 7 | subscription = Pay.subscription_model.find_by(processor: :stripe, processor_id: object.id) 8 | 9 | # We couldn't find the subscription for some reason, maybe it's from another service 10 | return if subscription.nil? 11 | 12 | # User canceled subscriptions have an ends_at 13 | # Automatically canceled subscriptions need this value set 14 | subscription.update!(ends_at: Time.at(object.ended_at)) if subscription.ends_at.blank? && object.ended_at.present? 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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 | 17 | <%= yield %> 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 initialize(pay_charge) 9 | @pay_charge = pay_charge 10 | end 11 | 12 | def charge 13 | Pay.braintree_gateway.transaction.find(processor_id) 14 | rescue ::Braintree::Braintree::Error => e 15 | raise Pay::Braintree::Error, e 16 | end 17 | 18 | def refund!(amount_to_refund) 19 | Pay.braintree_gateway.transaction.refund(processor_id, amount_to_refund / 100.0) 20 | pay_charge.update(amount_refunded: amount_to_refund) 21 | rescue ::Braintree::BraintreeError => e 22 | raise Pay::Braintree::Error, e 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/pay/paddle/webhooks/subscription_cancelled.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 3 | module Webhooks 4 | class SubscriptionCancelled 5 | def call(event) 6 | subscription = Pay.subscription_model.find_by(processor: :paddle, processor_id: event["subscription_id"]) 7 | 8 | # We couldn't find the subscription for some reason, maybe it's from another service 9 | return if subscription.nil? 10 | 11 | # User canceled subscriptions have an ends_at 12 | # Automatically canceled subscriptions need this value set 13 | subscription.update!(ends_at: Time.zone.parse(event["cancellation_effective_date"])) if subscription.ends_at.blank? && event["cancellation_effective_date"].present? 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/pay/user_mailer/receipt.html.erb: -------------------------------------------------------------------------------- 1 | We received payment for your subscription. Thanks for your business!
2 |
3 | Questions? Please reply to this email.
4 |
5 | ------------------------------------
6 | RECEIPT - SUBSCRIPTION
7 |
8 | Amount: USD <%= ActionController::Base.helpers.number_to_currency(params[:charge].amount / 100.0) %>
9 |
10 | Charged to: <%= params[:charge].charged_to %>
11 | Transaction ID: <%= params[:charge].id %>
12 | Date: <%= params[:charge].created_at %>
13 | <% if params[:charge].owner.extra_billing_info? %> 14 | <%= params[:charge].owner.extra_billing_info %>
15 | <% end %> 16 |
17 |
18 | <%= Pay.business_name %>
19 | <%= simple_format Pay.business_address %> 20 | ------------------------------------
21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | pay_subscription.update!(status: :past_due) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/controllers/pay/webhooks/paddle_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Pay 4 | class PaddleControllerTest < ActionDispatch::IntegrationTest 5 | include Engine.routes.url_helpers 6 | 7 | setup do 8 | @routes = Engine.routes 9 | end 10 | 11 | test "should handle post requests" do 12 | post webhooks_paddle_path 13 | assert_response :bad_request 14 | end 15 | 16 | test "should parse a paddle webhook" do 17 | user = User.create! 18 | params = fake_event "paddle/subscription_created" 19 | 20 | GlobalID::Locator.expects(:locate_signed).returns(user) 21 | assert_difference("Pay.subscription_model.count") do 22 | post webhooks_paddle_path, params: params 23 | assert_response :success 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /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 | delegate_event(verified_event) 10 | head :ok 11 | rescue ::Braintree::InvalidSignature 12 | head :bad_request 13 | end 14 | 15 | private 16 | 17 | def delegate_event(event) 18 | Pay::Webhooks.instrument type: "braintree.#{event.kind}", event: event 19 | end 20 | 21 | def verified_event 22 | Pay.braintree_gateway.webhook_notification.parse(params[:bt_signature], params[:bt_payload]) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /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/jobs/pay/email_sync_job_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Pay 4 | class EmailSyncJobTest < ActiveJob::TestCase 5 | setup do 6 | @billable = User.new email: "johnny@appleseed.com" 7 | end 8 | 9 | test "user with stripe as processor" do 10 | @billable.processor = "stripe" 11 | User.stubs(:find).returns(@billable) 12 | Pay::Stripe::Billable.any_instance.expects(:update_email!) 13 | Pay::EmailSyncJob.perform_now(@billable.id, @billable.class.name) 14 | end 15 | 16 | test "user with braintree as processor" do 17 | @billable.processor = "braintree" 18 | User.stubs(:find).returns(@billable) 19 | Pay::Braintree::Billable.any_instance.expects(:update_email!) 20 | Pay::EmailSyncJob.perform_now(@billable.id, @billable.class.name) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/pay/user_mailer/refund.html.erb: -------------------------------------------------------------------------------- 1 | We have processed your 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 | Amount: USD <%= ActionController::Base.helpers.number_to_currency(params[:charge].amount / 100.0) %>
10 |
11 | Refunded to: <%= params[:charge].charged_to %>
12 | Transaction ID: <%= params[:charge].id %>
13 | Date: <%= params[:charge].created_at %>
14 | <% if params[:charge].owner.extra_billing_info? %> 15 | <%= params[:charge].owner.extra_billing_info %>
16 | <% end %> 17 |
18 |
19 | <%= Pay.business_name %>
20 | <%= simple_format Pay.business_address %> 21 | ------------------------------------
22 | -------------------------------------------------------------------------------- /lib/pay/stripe/webhooks/charge_succeeded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class ChargeSucceeded 5 | def call(event) 6 | object = event.data.object 7 | billable = Pay.find_billable(processor: :stripe, processor_id: object.customer) 8 | 9 | return unless billable.present? 10 | return if billable.charges.where(processor_id: object.id).any? 11 | 12 | charge = Pay::Stripe::Billable.new(billable).save_pay_charge(object) 13 | notify_user(billable, charge) 14 | end 15 | 16 | def notify_user(billable, charge) 17 | if Pay.send_emails && charge.respond_to?(:receipt) 18 | Pay::UserMailer.with(billable: billable, charge: charge).receipt.deliver_later 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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 | billable = Pay.find_billable(processor: :stripe, processor_id: object.id) 8 | 9 | # Couldn't find user, we can skip 10 | return unless billable.present? 11 | 12 | billable.update( 13 | processor_id: nil, 14 | trial_ends_at: nil, 15 | card_type: nil, 16 | card_last4: nil, 17 | card_exp_month: nil, 18 | card_exp_year: nil 19 | ) 20 | 21 | billable.subscriptions.update_all( 22 | trial_ends_at: nil, 23 | ends_at: Time.zone.now 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /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_subscription = Pay.subscription_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | pay_subscription.update!(status: :active) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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 "braintree/subscription_cancelled" 6 | end 7 | 8 | test "it sets ends_at on the subscription" do 9 | user = User.create!( 10 | email: "gob@bluth.com", 11 | processor: :braintree, 12 | processor_id: @event.subscription.transactions.first.customer_details.id 13 | ) 14 | 15 | subscription = user.subscriptions.create!( 16 | processor: :braintree, 17 | processor_id: @event.subscription.id, 18 | name: "default", 19 | processor_plan: "some-plan", 20 | status: "active" 21 | ) 22 | 23 | Pay::Braintree::Webhooks::SubscriptionCanceled.new.call(@event) 24 | 25 | assert subscription.reload.cancelled? 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/verification/paddle_public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3CGT82ixrOTpCjAyY9pI 3 | GthIx4HzZtnO519VECl7wouV4nSD/09YDkyeujygarlYuKjILra4/yuwHoQGD/En 4 | EdADYfNgBNYMuiUhsiXArPQRuqRKSi3xboZEkeDLaeABtxr/5VWyHFtxbSe8FMa6 5 | st0Lm6zrOauYkYRbWF+8+6pp4CVNsFSBjP8PLum0zN9Uh44DyFI6qlJ3xA5Uxcr/ 6 | Ew/Fp3eDPloqqa2MNCZnkJwft5rLVz/B5YWf5jY515OKl+OU/t+FCtxKpjj74ug7 7 | 9Vk5cZsA3044VClsusI3qj/iRXPWtBNKUpj0tknT5Q9J94twv3kfa+XMu61uOw3k 8 | Vjhf62W6CYt5jtQdk/HgxzMP64ouvf0zVDKHoJtprU524IXOYxK7tiDXuktpVAw3 9 | SnNh/bpN0qD2D7TGVzHeajgcppxjBYJc6OzXHToiyIiLGqZdP0ewzfOIKGkW9tOi 10 | Y/1kSzlkJrQ6sfM5aKg6SSsos0+TBY9t7/UZFVKKe7J4ijSRmsFd+DVmc2KHHReK 11 | rd1tEBa2GQeJU9F0iM5EVcYnXeXzHRNha54snsp5ZYXSAmsYZCAutdh+gWI2Alni 12 | P97ZBsCbv7RHLQOY60CXkKnILEPhu4u5Kp7P1Ez1deGf/mcnZz21RCdC7PhqPXtm 13 | WtpkNrCK6BeRMj0XZG7oB3MCAwEAAQ== 14 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /app/views/pay/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/subscription_renewing.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class SubscriptionRenewing 5 | def call(event) 6 | # Event is of type "invoice" see: 7 | # https://stripe.com/docs/api/invoices/object 8 | subscription = Pay.subscription_model.find_by( 9 | processor: :stripe, 10 | processor_id: event.data.object.subscription 11 | ) 12 | date = Time.zone.at(event.data.object.next_payment_attempt) 13 | notify_user(subscription.owner, subscription, date) if subscription.present? 14 | end 15 | 16 | def notify_user(billable, subscription, date) 17 | if Pay.send_emails 18 | Pay::UserMailer.with(billable: billable, subscription: subscription, date: date).subscription_renewing.deliver_later 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/pay/braintree/webhooks/subscription_charged_successfully.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Braintree::Webhooks::SubscriptionChargedSuccessfullyTest < ActiveSupport::TestCase 4 | setup do 5 | @event = braintree_event "braintree/subscription_charged_successfully" 6 | end 7 | 8 | test "it sets ends_at on the subscription" do 9 | user = User.create!( 10 | email: "gob@bluth.com", 11 | processor: :braintree, 12 | processor_id: @event.subscription.transactions.first.customer_details.id 13 | ) 14 | 15 | user.subscriptions.create!( 16 | processor: :braintree, 17 | processor_id: @event.subscription.id, 18 | name: "default", 19 | processor_plan: "some-plan", 20 | status: "active" 21 | ) 22 | 23 | assert_difference "user.charges.count" do 24 | Pay::Braintree::Webhooks::SubscriptionChargedSuccessfully.new.call(@event) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/pay/orm_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pay 4 | module Generators 5 | module OrmHelpers 6 | def model_contents 7 | <<-CONTENT 8 | include Pay::Billable 9 | CONTENT 10 | end 11 | 12 | private 13 | 14 | def model_exists? 15 | File.exist?(File.join(destination_root, model_path)) 16 | end 17 | 18 | def migration_exists?(table_name) 19 | Dir.glob("#{File.join(destination_root, migration_path)}/[0-9]*_*.rb").grep(/\d+_add_devise_to_#{table_name}.rb$/).first 20 | end 21 | 22 | def migration_path 23 | if Rails.version >= "5.0.3" 24 | db_migrate_path 25 | else 26 | @migration_path ||= File.join("db", "migrate") 27 | end 28 | end 29 | 30 | def model_path 31 | @model_path ||= File.join("app", "models", "#{file_path}.rb") 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/pay/stripe/webhooks/payment_intent_succeeded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class PaymentIntentSucceeded 5 | def call(event) 6 | object = event.data.object 7 | billable = Pay.find_billable(processor: :stripe, processor_id: object.customer) 8 | 9 | return unless billable.present? 10 | 11 | object.charges.data.each do |charge| 12 | next if billable.charges.where(processor_id: charge.id).any? 13 | 14 | charge = Pay::Stripe::Billable.new(billable).save_pay_charge(charge) 15 | notify_user(billable, charge) 16 | end 17 | end 18 | 19 | def notify_user(billable, charge) 20 | if Pay.send_emails && charge.respond_to?(:receipt) 21 | Pay::UserMailer.with(billable: billable, charge: charge).receipt.deliver_later 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle/charges_controller.rb: -------------------------------------------------------------------------------- 1 | class Paddle::ChargesController < ApplicationController 2 | before_action :set_charge, only: [:show, :refund] 3 | 4 | def index 5 | @charges = Pay::Charge.where(processor: :paddle).order(created_at: :desc) 6 | end 7 | 8 | def show 9 | end 10 | 11 | def new 12 | end 13 | 14 | def create 15 | current_user.processor = params[:processor] 16 | current_user.card_token = params[:card_token] 17 | charge = current_user.charge(params[:amount]) 18 | redirect_to paddle_charge_path(charge) 19 | rescue Pay::Error => e 20 | flash[:alert] = e.message 21 | render :new 22 | end 23 | 24 | def refund 25 | @charge.refund! 26 | rescue Pay::Error => e 27 | flash[:alert] = e.message 28 | ensure 29 | redirect_to paddle_charge_path(@charge) 30 | end 31 | 32 | private 33 | 34 | def set_charge 35 | @charge = Pay::Charge.find(params[:id]) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/pay/stripe/billable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Stripe::BillableTest < ActiveSupport::TestCase 4 | setup do 5 | @user = User.create!(email: "gob@bluth.com", processor: :stripe) 6 | 7 | # Create Stripe customer 8 | @user.customer 9 | end 10 | 11 | test "stripe subscription and one time charge" do 12 | @user.update_card("pm_card_visa") 13 | @user.subscribe( 14 | name: "default", 15 | plan: "default", 16 | add_invoice_items: [ 17 | {price: "price_1ILVZaKXBGcbgpbZQ26kgXWG"} # T-Shirt $15 18 | ] 19 | ) 20 | 21 | invoice_id = Pay::Subscription.last.processor_subscription.latest_invoice 22 | invoice = ::Stripe::Invoice.retrieve(invoice_id) 23 | assert_equal 25_00, invoice.total 24 | assert_not_nil invoice.lines.data.find { |l| l.plan&.id == "default" } 25 | assert_not_nil invoice.lines.data.find { |l| l.price&.id == "price_1ILVZaKXBGcbgpbZQ26kgXWG" } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/fixtures/stripe/customer_deleted_event.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 | } -------------------------------------------------------------------------------- /test/support/fixtures/stripe/customer_updated_event.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 | } -------------------------------------------------------------------------------- /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.where(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.processor = params[:processor] 16 | current_user.card_token = params[:card_token] 17 | charge = current_user.charge(params[:amount]) 18 | redirect_to braintree_charge_path(charge) 19 | rescue Pay::Error => e 20 | flash[:alert] = e.message 21 | render :new 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 | -------------------------------------------------------------------------------- /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 | subscription = Pay.subscription_model.find_by(processor: :stripe, processor_id: object.subscription) 12 | return if subscription.nil? 13 | billable = subscription.owner 14 | 15 | notify_user(billable, event.data.object.payment_intent, subscription) 16 | end 17 | 18 | def notify_user(billable, payment_intent_id, subscription) 19 | if Pay.send_emails 20 | Pay::UserMailer.with(billable: billable, payment_intent_id: payment_intent_id, subscription: subscription).payment_action_required.deliver_later 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/pay/braintree/error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Braintree::ErrorTest < ActiveSupport::TestCase 4 | setup do 5 | @user = User.create!(email: "gob@bluth.com", processor: :braintree) 6 | end 7 | 8 | test "raising braintree failures keep the same message" do 9 | exception = assert_raises(Pay::Braintree::Error) { @user.charge(0) } 10 | assert_match "Amount must be greater than zero.", exception.to_s 11 | assert_equal ::Braintree::ErrorResult, exception.cause.class 12 | end 13 | 14 | test "re-raising braintree exceptions keep the same message" do 15 | exception = assert_raises(Pay::Braintree::Error) { 16 | begin 17 | raise ::Braintree::AuthorizationError, "Oh no!" 18 | rescue ::Braintree::AuthorizationError => e 19 | raise Pay::Braintree::Error, e 20 | end 21 | } 22 | assert_match "Oh no!", exception.to_s 23 | assert_equal ::Braintree::AuthorizationError, exception.cause.class 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /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"] 11 | s.email = ["jason@thecharnes.com", "excid3@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", ">= 4.2" 25 | 26 | s.add_development_dependency "minitest-rails", ">= 6", "< 7.0" 27 | s.add_development_dependency "mocha" 28 | s.add_development_dependency "standardrb" 29 | s.add_development_dependency "vcr" 30 | s.add_development_dependency "webmock" 31 | end 32 | -------------------------------------------------------------------------------- /lib/pay/env.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Env 3 | private 4 | 5 | # Search for environment variables 6 | # 7 | # We must handle a lot of different cases, including the new Rails 6 8 | # environment separated credentials files which have no nesting for 9 | # the current environment. 10 | # 11 | # 1. Check environment variable 12 | # 2. Check environment scoped credentials, then secrets 13 | # 3. Check unscoped credentials, then secrets 14 | def find_value_by_name(scope, name) 15 | ENV["#{scope.upcase}_#{name.upcase}"] || 16 | credentials&.dig(env, scope, name) || 17 | secrets&.dig(env, scope, name) || 18 | credentials&.dig(scope, name) || 19 | secrets&.dig(scope, name) 20 | end 21 | 22 | def env 23 | Rails.env.to_sym 24 | end 25 | 26 | def secrets 27 | Rails.application.secrets 28 | end 29 | 30 | def credentials 31 | Rails.application.credentials if Rails.application.respond_to?(:credentials) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /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 | user = User.create! 18 | Pay.subscription_model.create!( 19 | owner: user, 20 | processor: :braintree, 21 | processor_id: "f6rnpm", 22 | processor_plan: "default", 23 | name: "default", 24 | status: "active" 25 | ) 26 | 27 | params = fake_event "braintree/subscription_charged_successfully" 28 | 29 | assert_difference("user.charges.count") do 30 | post webhooks_braintree_path, params: params 31 | assert_response :success 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/dummy/app/views/payment_methods/show.html.erb: -------------------------------------------------------------------------------- 1 |

Payment Method

2 | 3 |
4 |
Processor
5 | <%= current_user.processor&.titleize || "None"%> 6 | 7 |
8 |
Payment Method Details
9 | <% if current_user.card_last4? && current_user.paypal? %> 10 |
<%= current_user.card_type.titleize %> (<%= current_user.card_last4 %>)
11 | <% elsif current_user.card_last4? %> 12 |
<%= current_user.card_type.titleize %> ending in <%= current_user.card_last4 %>
13 |
Expires <%= current_user.card_exp_month %> / <%= current_user.card_exp_year %>
14 | <% else %> 15 | No card on file. 16 | <% end %> 17 |
18 | 19 |
20 |
Update Payment Method
21 | <%= link_to "Stripe", edit_stripe_payment_method_path, class: "d-block" %> 22 | <%= link_to "Braintree", edit_braintree_payment_method_path, class: "d-block" %> 23 | <%= link_to "Paddle", edit_paddle_payment_method_path, class: "d-block" %> 24 |
25 | -------------------------------------------------------------------------------- /test/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 1ca8a0578a7f730e9b976b2d4caaaf4a739bb5e391de413127bf748c9ffe8abc9784eaae87eb7fbf88543bc3ee67c74576e1763f75b6a6c6bc2071f834782652 15 | 16 | test: 17 | secret_key_base: ae102b18ad3fcf12beea3626f5dc743633e494919238f2153fcd5720e7625b62006350d5e11cafb0f3383417d9823eb2c8e2133cdddbd7f4b032e0412105d299 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /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: "excid3/appraisal", branch: "fix-bundle-env" 16 | 17 | gem "braintree", ">= 2.92.0", "< 4.0" 18 | gem "stripe", ">= 2.8" 19 | gem "paddle_pay", "~> 0.1.0" 20 | 21 | # Test against different databases 22 | gem "sqlite3", "~> 1.4" 23 | gem "mysql2" 24 | gem "pg" 25 | 26 | # Used for the dummy Rails app integration 27 | gem "puma" 28 | gem "standard" 29 | gem "turbolinks" 30 | gem "web-console", group: :development 31 | gem "webpacker" 32 | -------------------------------------------------------------------------------- /app/controllers/pay/webhooks/paddle_controller.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Webhooks 3 | class PaddleController < Pay::ApplicationController 4 | if Rails.application.config.action_controller.default_protect_from_forgery 5 | skip_before_action :verify_authenticity_token 6 | end 7 | 8 | def create 9 | delegate_event(verified_event) 10 | head :ok 11 | rescue Pay::Paddle::Error 12 | head :bad_request 13 | end 14 | 15 | private 16 | 17 | def delegate_event(event) 18 | Pay::Webhooks.instrument type: "paddle.#{type}", event: event 19 | end 20 | 21 | def type 22 | params[:alert_name] 23 | end 24 | 25 | def verified_event 26 | event = check_params.as_json 27 | verifier = Pay::Paddle::Webhooks::SignatureVerifier.new(event) 28 | return event if verifier.verify 29 | raise Pay::Paddle::Error, "Unable to verify Paddle webhook event" 30 | end 31 | 32 | def check_params 33 | params.except(:action, :controller).permit! 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /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_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | billable = pay_subscription.owner 15 | charge = Pay::Braintree::Billable.new(billable).save_transaction(subscription.transactions.first) 16 | 17 | if Pay.send_emails 18 | Pay::UserMailer.with(billable: billable, charge: charge).receipt.deliver_later 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/pay/braintree/charge_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | 4 | class Pay::Braintree::Charge::Test < ActiveSupport::TestCase 5 | setup do 6 | @billable = User.new email: "test@example.com" 7 | @billable.processor = "braintree" 8 | end 9 | 10 | test "can partially refund a transaction" do 11 | @billable.card_token = "fake-valid-visa-nonce" 12 | 13 | charge = @billable.charge(29_00) 14 | assert charge.present? 15 | 16 | charge.refund!(10_00) 17 | assert_equal 10_00, charge.amount_refunded 18 | end 19 | 20 | test "can fully refund a transaction" do 21 | @billable.card_token = "fake-valid-visa-nonce" 22 | 23 | charge = @billable.charge(37_00) 24 | assert charge.present? 25 | 26 | charge.refund! 27 | assert_equal 37_00, charge.amount_refunded 28 | end 29 | 30 | test "you can ask the charge for the type" do 31 | assert Pay::Charge.new(processor: "stripe").stripe? 32 | assert Pay::Charge.new(processor: "braintree").braintree? 33 | assert Pay::Charge.new(processor: "braintree", card_type: "PayPal").paypal? 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_model.find_by(processor: :braintree, processor_id: subscription.id) 12 | return unless pay_subscription.present? 13 | 14 | # billable = pay_subscription.owner 15 | # charge = Pay::Braintree::Billable.new(billable).save_transaction(subscription.transactions.first) 16 | 17 | # if Pay.send_emails 18 | # Pay::UserMailer.with(billable: billable, charge: charge).receipt.deliver_later 19 | # end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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.where(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.processor = params[:processor] 17 | current_user.card_token = params[:card_token] 18 | charge = current_user.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 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 | -------------------------------------------------------------------------------- /docs/webhooks.md: -------------------------------------------------------------------------------- 1 | # Webhooks with Pay 2 | 3 | Pay comes with a bunch of different webhook handlers built-in. Each payment processor has different requirements for handling webhooks and we've implemented all the basic ones for you. 4 | 5 | ## Event Naming 6 | 7 | Since we support multiple payment providers, each event type needs to be prefixed with the payment provider: 8 | 9 | ```ruby 10 | "stripe.charge.succeeded" 11 | "braintree.subscription_charged_successfully" 12 | "paddle.subscription_created" 13 | ``` 14 | 15 | ## Adding a custom Webhook Listener 16 | 17 | To add your own listener, you can simply subscribe to the event type. 18 | 19 | ```ruby 20 | Pay::Webhooks.delegator.subscribe "stripe.charge.succeeded", MyChargeSucceededProcessor.new 21 | 22 | class MyChargeSucceededProcessor 23 | def call(event) 24 | # do your processing here 25 | end 26 | end 27 | ``` 28 | 29 | ## Unsubscribing from a webhook listener 30 | 31 | Need to unsubscribe or disable one of the default webhook processors? Simply unsubscribe from the event name: 32 | 33 | ```ruby 34 | Pay::Webhooks.delegator.unsubscribe "stripe.charge.succeeded" 35 | 36 | ``` 37 | 38 | -------------------------------------------------------------------------------- /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/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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/invoice.payment_action_required.json"), object_class: OpenStruct) 7 | 8 | # Create user and subscription 9 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: @event.data.object.customer) 10 | @subscription = @user.subscriptions.create!( 11 | processor: :stripe, 12 | processor_id: @event.data.object.subscription, 13 | name: "default", 14 | processor_plan: "some-plan", 15 | status: "requires_action" 16 | ) 17 | end 18 | 19 | test "it sends an email" do 20 | assert_enqueued_jobs 1 do 21 | Pay::Stripe::Webhooks::PaymentActionRequired.new.call(@event) 22 | end 23 | end 24 | 25 | test "ignores if subscription doesn't exist" do 26 | @subscription.destroy! 27 | assert_no_enqueued_jobs do 28 | Pay::Stripe::Webhooks::PaymentActionRequired.new.call(@event) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/pay/paddle/webhooks/subscription_payment_refunded_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::Webhooks::SubscriptionPaymentRefundedTest < ActiveSupport::TestCase 4 | setup do 5 | @data = JSON.parse(File.read("test/support/fixtures/paddle/subscription_payment_refunded.json")) 6 | end 7 | 8 | test "a charge is updated with refunded amount" do 9 | @user = User.create!(email: "gob@bluth.com", processor: :paddle, processor_id: @data["user_id"]) 10 | charge = @user.charges.create!(processor: :paddle, processor_id: @data["subscription_payment_id"], amount: 16, card_type: "card") 11 | 12 | Pay::Paddle::Webhooks::SubscriptionPaymentRefunded.new.call(@data) 13 | 14 | assert_equal Integer(@data["gross_refund"].to_f * 100), charge.reload.amount_refunded 15 | end 16 | 17 | test "a charge isn't updated with the refunded amount if a corresponding charge can't be found (obviously)" do 18 | @user = User.create!(email: "gob@bluth.com", processor: :paddle, processor_id: "does-not-exist") 19 | charge = @user.charges.create!(processor: :paddle, processor_id: "doesntexist", amount: 500, card_type: "card") 20 | 21 | assert_nil charge.reload.amount_refunded 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/pay/chargeable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Charge::Test < ActiveSupport::TestCase 4 | setup do 5 | @charge = Pay.charge_model.new 6 | end 7 | 8 | test "belongs to a polymorphic owner" do 9 | @charge.owner = User.new 10 | assert_equal User, @charge.owner.class 11 | @charge.owner = Team.new 12 | assert_equal Team, @charge.owner.class 13 | end 14 | 15 | test "#charged_to" do 16 | @charge.card_type = "VISA" 17 | @charge.card_last4 = 1234 18 | assert_equal "VISA (**** **** **** 1234)", @charge.charged_to 19 | end 20 | 21 | test "finds polymorphic charge" do 22 | user_chargeable = User.create! email: "test@example.com", id: 1001 23 | team_chargeable = Team.create! id: 1001, owner: user_chargeable 24 | 25 | charge = Pay.charge_model.create!( 26 | owner: team_chargeable, amount: 1, processor: "stripe", processor_id: "1", card_type: "VISA" 27 | ) 28 | 29 | assert_equal [], user_chargeable.charges 30 | assert_equal [charge], team_chargeable.charges 31 | end 32 | 33 | test "stores data about the charge" do 34 | data = {"foo" => "bar"} 35 | @charge.update(data: data) 36 | assert_equal data, @charge.data 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/pay/receipts.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Receipts 3 | def filename 4 | "receipt-#{created_at.strftime("%Y-%m-%d")}.pdf" 5 | end 6 | 7 | def product 8 | Pay.application_name 9 | end 10 | 11 | # Must return a file object 12 | def receipt 13 | receipt_pdf.render 14 | end 15 | 16 | def receipt_pdf 17 | ::Receipts::Receipt.new( 18 | id: id, 19 | product: product, 20 | company: { 21 | name: Pay.business_name, 22 | address: Pay.business_address, 23 | email: Pay.support_email 24 | }, 25 | line_items: line_items 26 | ) 27 | end 28 | 29 | def line_items 30 | line_items = [ 31 | [I18n.t("receipt.date"), created_at.to_s], 32 | [I18n.t("receipt.account_billed"), "#{owner.name} (#{owner.email})"], 33 | [I18n.t("receipt.product"), product], 34 | [I18n.t("receipt.amount"), ActionController::Base.helpers.number_to_currency(amount / 100.0)], 35 | [I18n.t("receipt.charged_to"), charged_to] 36 | ] 37 | line_items << [I18n.t("receipt.additional_info"), owner.extra_billing_info] if owner.extra_billing_info? 38 | line_items 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/pay/payment.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class Payment 3 | attr_reader :intent 4 | 5 | delegate :id, :amount, :client_secret, :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 validate 45 | if requires_payment_method? 46 | raise Pay::InvalidPaymentMethod.new(self) 47 | elsif requires_action? 48 | raise Pay::ActionRequired.new(self) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/pay/paddle/charge.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 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 | return unless owner.subscription 14 | payments = PaddlePay::Subscription::Payment.list({subscription_id: owner.subscription.processor_id}) 15 | charges = payments.select { |p| p[:id].to_s == processor_id } 16 | charges.try(:first) 17 | rescue ::PaddlePay::PaddlePayError => e 18 | raise Pay::Paddle::Error, e 19 | end 20 | 21 | def refund!(amount_to_refund) 22 | return unless owner.subscription 23 | payments = PaddlePay::Subscription::Payment.list({subscription_id: owner.subscription.processor_id, is_paid: 1}) 24 | if payments.count > 0 25 | PaddlePay::Subscription::Payment.refund(payments.last[:id], {amount: amount_to_refund}) 26 | pay_charge.update(amount_refunded: amount_to_refund) 27 | else 28 | raise Error, "Payment not found" 29 | end 30 | rescue ::PaddlePay::PaddlePayError => e 31 | raise Pay::Paddle::Error, e 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/dummy/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 | -------------------------------------------------------------------------------- /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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/subscription_renewing_event.json"), object_class: OpenStruct) 7 | 8 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: @event.data.object.customer) 9 | end 10 | 11 | test "an email is sent to the user when subscription is renewing" do 12 | create_subscription(processor_id: @event.data.object.subscription) 13 | # Time.zone.at(@event.data.object.next_payment_attempt) 14 | 15 | Pay::Stripe::Webhooks::SubscriptionRenewing.new.call(@event) 16 | assert_enqueued_emails 1 17 | end 18 | 19 | test "an email is not sent when subscription can't be found" do 20 | create_subscription(processor_id: "does-not-exist") 21 | 22 | assert_no_enqueued_emails do 23 | Pay::Stripe::Webhooks::SubscriptionRenewing.new.call(@event) 24 | end 25 | end 26 | 27 | def create_subscription(processor_id:) 28 | @user.subscriptions.create!(processor: :stripe, processor_id: processor_id, name: "default", processor_plan: "some-plan", status: "active") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literals: true 2 | 3 | Rails.application.routes.draw do 4 | resource :payment_method 5 | 6 | namespace :braintree do 7 | resource :payment_method, namespace: :braintree 8 | resources :subscriptions do 9 | member do 10 | patch :cancel 11 | patch :resume 12 | end 13 | end 14 | resources :charges do 15 | member do 16 | patch :refund 17 | end 18 | end 19 | end 20 | 21 | namespace :paddle do 22 | resource :payment_method, namespace: :paddle 23 | resources :subscriptions do 24 | member do 25 | patch :cancel 26 | patch :resume 27 | end 28 | end 29 | resources :charges do 30 | member do 31 | patch :refund 32 | end 33 | end 34 | end 35 | 36 | namespace :stripe do 37 | resource :payment_method, namespace: :stripe 38 | resources :subscriptions do 39 | member do 40 | patch :cancel 41 | patch :resume 42 | end 43 | end 44 | resources :charges do 45 | member do 46 | patch :refund 47 | end 48 | end 49 | namespace :charges do 50 | resource :import 51 | end 52 | resource :checkout, namespace: :stripe 53 | end 54 | 55 | root to: "main#show" 56 | end 57 | -------------------------------------------------------------------------------- /test/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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/charge_refunded_event.json"), object_class: OpenStruct) 7 | end 8 | 9 | test "a charge is updated with refunded amount" do 10 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: @event.data.object.customer) 11 | charge = @user.charges.create!(processor: :stripe, processor_id: @event.data.object.id, amount: 500, card_type: "Visa", card_last4: "4444", card_exp_month: 1, card_exp_year: 2019) 12 | 13 | Pay::Stripe::Webhooks::ChargeRefunded.new.call(@event) 14 | 15 | assert_equal 500, charge.reload.amount_refunded 16 | end 17 | 18 | test "a charge isn't updated with the refunded amount if a corresponding charge can't be found (obviously)" do 19 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: "does-not-exist") 20 | charge = @user.charges.create!(processor: :stripe, processor_id: "doesntexist", amount: 500, card_type: "Visa", card_last4: "4444", card_exp_month: 1, card_exp_year: 2019) 21 | 22 | assert_nil charge.reload.amount_refunded 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/pay/billable/sync_email.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Billable 3 | module SyncEmail 4 | # Sync email address changes from the model to the 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 | # Processor classes simply need to implement a method named: 9 | # 10 | # update_PROCESSOR_email! 11 | # 12 | # This method should take the email address on the billable 13 | # object and update the associated API record. 14 | 15 | extend ActiveSupport::Concern 16 | 17 | included do 18 | after_update :enqeue_sync_email_job, if: :should_sync_email_with_processor? 19 | end 20 | 21 | def should_sync_email_with_processor? 22 | try(:saved_change_to_email?) 23 | end 24 | 25 | def sync_email_with_processor 26 | payment_processor.update_email! 27 | end 28 | 29 | private 30 | 31 | def enqeue_sync_email_job 32 | # Only update if the processor id is the same 33 | # This prevents duplicate API hits if this is their first time 34 | if processor_id? && !saved_change_to_processor_id? && saved_change_to_email? 35 | EmailSyncJob.perform_later(id, self.class.name) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/fixtures/stripe/payment_method.updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "id": "pm_1INhmtKXBGcbgpbZd73T5GF5", 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": "jenny@example.com", 15 | "name": null, 16 | "phone": "+15555555555" 17 | }, 18 | "card": { 19 | "brand": "visa", 20 | "checks": { 21 | "address_line1_check": null, 22 | "address_postal_code_check": null, 23 | "cvc_check": "pass" 24 | }, 25 | "country": "US", 26 | "exp_month": 8, 27 | "exp_year": 2022, 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": 123456789, 44 | "customer": "cus_1234567890", 45 | "livemode": false, 46 | "metadata": { 47 | "order_id": "123456789" 48 | }, 49 | "type": "card" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/paddle/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | class Paddle::SubscriptionsController < ApplicationController 2 | before_action :set_subscription, only: [:show, :edit, :update, :destroy, :cancel, :resume] 3 | 4 | def index 5 | @subscriptions = Pay::Subscription.where(processor: :paddle).order(created_at: :desc) 6 | end 7 | 8 | def show 9 | end 10 | 11 | def new 12 | end 13 | 14 | def create 15 | current_user.processor = params[:processor] 16 | current_user.card_token = params[:card_token] 17 | subscription = current_user.subscribe(plan: params[:plan_id]) 18 | redirect_to paddle_subscription_path(subscription) 19 | rescue Pay::Error => e 20 | flash[:alert] = e.message 21 | redirect_to new_paddle_subscription_path 22 | end 23 | 24 | def edit 25 | end 26 | 27 | def update 28 | end 29 | 30 | def destroy 31 | @subscription.cancel_now! 32 | redirect_to paddle_subscription_path(@subscription) 33 | end 34 | 35 | def cancel 36 | @subscription.cancel 37 | redirect_to paddle_subscription_path(@subscription) 38 | end 39 | 40 | def resume 41 | @subscription.resume 42 | redirect_to paddle_subscription_path(@subscription) 43 | end 44 | 45 | private 46 | 47 | def set_subscription 48 | @subscription = Pay::Subscription.where(processor: :paddle).find(params[:id]) 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.where(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.processor = params[:processor] 16 | current_user.card_token = params[:card_token] 17 | subscription = current_user.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.where(processor: :braintree).find(params[:id]) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/customer_deleted_event.json"), object_class: OpenStruct) 7 | end 8 | 9 | test "a customers subscription information is nulled out upon deletion" do 10 | user = User.create!( 11 | email: "gob@bluth.com", 12 | processor: :stripe, 13 | processor_id: @event.data.object.id, 14 | card_type: "Visa", 15 | card_exp_month: 1, 16 | card_exp_year: 2019, 17 | card_last4: "4444", 18 | trial_ends_at: 3.days.from_now 19 | ) 20 | subscription = user.subscriptions.create!( 21 | processor: :stripe, 22 | processor_id: "sub_someid", 23 | name: "default", 24 | processor_plan: "some-plan", 25 | trial_ends_at: 3.days.from_now, 26 | status: "active" 27 | ) 28 | 29 | Pay::Stripe::Webhooks::CustomerDeleted.new.call(@event) 30 | 31 | assert_nil user.reload.processor_id 32 | assert_nil user.reload.card_type 33 | assert_nil user.reload.card_exp_month 34 | assert_nil user.reload.card_exp_year 35 | assert_nil user.reload.card_last4 36 | assert_nil user.reload.trial_ends_at 37 | 38 | assert_nil subscription.reload.trial_ends_at 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/pay/stripe/webhooks/subscription_updated.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class SubscriptionUpdated 5 | def call(event) 6 | object = event.data.object 7 | subscription = Pay.subscription_model.find_by(processor: :stripe, processor_id: object.id) 8 | 9 | return if subscription.nil? 10 | 11 | # Delete any subscription attempts that have expired 12 | if object.status == "incomplete_expired" 13 | subscription.destroy 14 | return 15 | end 16 | 17 | subscription.status = object.status 18 | subscription.quantity = object.quantity 19 | subscription.processor_plan = object.plan.id 20 | subscription.trial_ends_at = Time.at(object.trial_end) if object.trial_end.present? 21 | 22 | # If user was on trial, their subscription ends at the end of the trial 23 | subscription.ends_at = if object.cancel_at_period_end && subscription.on_trial? 24 | subscription.trial_ends_at 25 | 26 | # User wasn't on trial, so subscription ends at period end 27 | elsif object.cancel_at_period_end 28 | Time.at(object.current_period_end) 29 | 30 | # Subscription isn't marked to cancel at period end 31 | end 32 | 33 | subscription.save! 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/pay/fake_processor/subscription_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::FakeProcessor::Subscription::Test < ActiveSupport::TestCase 4 | setup do 5 | @billable = User.create!(email: "gob@bluth.com", processor: :fake_processor, processor_id: "17368056", pay_fake_processor_allowed: true) 6 | @subscription = @billable.subscribe 7 | end 8 | 9 | test "fake processor subscription" do 10 | assert_equal @subscription, @subscription.processor_subscription 11 | end 12 | 13 | test "fake processor cancel" do 14 | freeze_time do 15 | @subscription.cancel 16 | assert_equal Time.current.end_of_month.to_date, @subscription.ends_at.to_date 17 | end 18 | end 19 | 20 | test "fake processor cancel_now!" do 21 | @subscription.cancel_now! 22 | assert_not @subscription.active? 23 | end 24 | 25 | test "fake processor on_grace_period?" do 26 | freeze_time do 27 | @subscription.cancel 28 | assert @subscription.on_grace_period? 29 | end 30 | end 31 | 32 | test "fake processor resume" do 33 | freeze_time do 34 | @subscription.cancel 35 | assert_not_nil @subscription.ends_at 36 | @subscription.resume 37 | assert_nil @subscription.ends_at 38 | end 39 | end 40 | 41 | test "fake processor swap" do 42 | @subscription.swap("another_plan") 43 | assert_equal "another_plan", @subscription.processor_plan 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/pay/fake_processor/subscription.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module FakeProcessor 3 | class Subscription 4 | attr_reader :pay_subscription 5 | 6 | delegate :canceled?, 7 | :ends_at, 8 | :on_trial?, 9 | :owner, 10 | :processor_subscription, 11 | :processor_id, 12 | :prorate, 13 | :processor_plan, 14 | :quantity?, 15 | :quantity, 16 | to: :pay_subscription 17 | 18 | def initialize(pay_subscription) 19 | @pay_subscription = pay_subscription 20 | end 21 | 22 | def cancel 23 | pay_subscription.update(ends_at: Time.current.end_of_month) 24 | end 25 | 26 | def cancel_now! 27 | pay_subscription.update(ends_at: Time.current, status: :canceled) 28 | end 29 | 30 | def on_grace_period? 31 | canceled? && Time.zone.now < ends_at 32 | end 33 | 34 | def paused? 35 | false 36 | end 37 | 38 | def pause 39 | raise NotImplementedError, "FakeProcessor does not support pausing subscriptions" 40 | end 41 | 42 | def resume 43 | unless on_grace_period? 44 | raise StandardError, "You can only resume subscriptions within their grace period." 45 | end 46 | 47 | pay_subscription.update(ends_at: nil, status: :active) 48 | end 49 | 50 | def swap(plan) 51 | pay_subscription.update(processor_plan: plan) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/pay/stripe/checkout_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Stripe::CheckoutTest < ActiveSupport::TestCase 4 | setup do 5 | @user = User.create!(email: "gob@bluth.com", processor: :stripe) 6 | 7 | # Create Stripe customer 8 | @user.customer 9 | end 10 | 11 | test "checkout setup session" do 12 | session = @user.payment_processor.checkout(mode: "setup") 13 | assert_equal "setup", session.mode 14 | end 15 | 16 | test "checkout payment session" do 17 | session = @user.payment_processor.checkout(mode: "payment", line_items: "price_1ILVZaKXBGcbgpbZQ26kgXWG") 18 | assert_equal "payment", session.mode 19 | end 20 | 21 | test "checkout subscription session" do 22 | session = @user.payment_processor.checkout(mode: "subscription", line_items: "default") 23 | assert_equal "subscription", session.mode 24 | end 25 | 26 | test "billing portal session" do 27 | session = @user.payment_processor.billing_portal 28 | assert_not_nil session.url 29 | end 30 | 31 | test "raises an error with empty default_url_options" do 32 | # This should raise: 33 | # ArgumentError: Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true 34 | 35 | Rails.application.config.action_mailer.stub :default_url_options, nil do 36 | assert_raises ArgumentError do 37 | @user.payment_processor.checkout(mode: "setup") 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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/pay/fake_processor/billable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::FakeProcessor::Billable::Test < ActiveSupport::TestCase 4 | setup do 5 | @billable = User.create!(email: "gob@bluth.com", processor: :fake_processor, processor_id: "17368056", pay_fake_processor_allowed: true) 6 | @billable.reload 7 | end 8 | 9 | test "doesn't allow fake processor by default" do 10 | assert_not User.new(email: "gob@bluth.com", processor: :fake_processor, processor_id: "17368056").valid? 11 | end 12 | 13 | test "allows fake processor if enabled" do 14 | assert User.new(email: "gob@bluth.com", processor: :fake_processor, processor_id: "17368056", pay_fake_processor_allowed: true).valid? 15 | end 16 | 17 | test "doesn't validate fake processor if processor didn't change" do 18 | assert @billable.update(email: "michael@bluth.com") 19 | end 20 | 21 | test "validates fake processor if processor changed" do 22 | @billable.update(processor: :stripe) 23 | assert_not @billable.update(processor: :fake_processor, processor_id: 12345) 24 | end 25 | 26 | test "fake processor customer" do 27 | assert_equal @billable, @billable.payment_processor.customer 28 | end 29 | 30 | test "fake processor charge" do 31 | assert_difference "Pay::Charge.count" do 32 | @billable.charge(10_00) 33 | end 34 | end 35 | 36 | test "fake processor subscribe" do 37 | assert_difference "Pay::Subscription.count" do 38 | @billable.subscribe 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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 | @event = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/customer_updated_event.json"), object_class: OpenStruct) 7 | end 8 | 9 | test "update_card_from stripe is called upon customer update" do 10 | user = User.create!( 11 | email: "gob@bluth.com", 12 | processor: :stripe, 13 | processor_id: @event.data.object.id 14 | ) 15 | user.subscriptions.create!( 16 | processor: :stripe, 17 | processor_id: "sub_someid", 18 | name: "default", 19 | processor_plan: "some-plan", 20 | status: "active" 21 | ) 22 | 23 | Pay::Stripe::Billable.any_instance.expects(:sync_card_from_stripe) 24 | Pay::Stripe::Webhooks::CustomerUpdated.new.call(@event) 25 | end 26 | 27 | test "update_card_from stripe is not called if user can't be found" do 28 | user = User.create!( 29 | email: "gob@bluth.com", 30 | processor: :stripe, 31 | processor_id: "does-not-exist" 32 | ) 33 | user.subscriptions.create!( 34 | processor: :stripe, 35 | processor_id: "sub_someid", 36 | name: "default", 37 | processor_plan: "some-plan", 38 | status: "active" 39 | ) 40 | 41 | Pay::Stripe::Billable.any_instance.expects(:sync_card_from_stripe).never 42 | Pay::Stripe::Webhooks::CustomerUpdated.new.call(@event) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/subscription_cancelled.json: -------------------------------------------------------------------------------- 1 | { 2 | "alert_id": "1440248908", 3 | "alert_name": "subscription_cancelled", 4 | "cancellation_effective_date": "2020-11-18 11:18:16", 5 | "checkout_id": "7-1bfa34cf3136556-972299a6b8", 6 | "currency": "USD", 7 | "email": "wilkinson.cicero@example.net", 8 | "event_time": "2020-11-16 16:45:09", 9 | "linked_subscriptions": "6, 1, 6", 10 | "marketing_consent": "", 11 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}", 12 | "quantity": "49", 13 | "status": "deleted", 14 | "subscription_id": "7", 15 | "subscription_plan_id": "9", 16 | "unit_price": "unit_price", 17 | "user_id": "9", 18 | "p_signature": "17ffO2bIL20TjljrhhETh3PfpNyDi8WLvx+cK1DVy1EEDUPPCT3CCAITuUraVKJjWD3hWsOx1/KVNUaqzqMWAA5GWa4cPf/iaAXDUGXCpsNwZQR7XeZXmQnD8xXHH2/BaL7BStGJ4iyGqbZynzJM0gIV+2j6ZXa3uvkReZMVlU9AUZoF1I4su3U15509bsjcPw/2UOhj6GFKH0WNwcsPkhzd9Xzfxcof5NWDlmIpiEnHgnBUGbdqpHp46ey2uavA5hpIFTs8chJgAQGh/suDuuK+GKE0CyIRCnuJP7Jd57qqCUVIWPgucMErKJWeiO3QOyQPRegIF0RlfJhT/i87hg1iJ5loxpc+a+ljNvQcrPpi6fUE6EGiaqOhaSmAD2/+VDy+y5UGwl0E9GU4fPND9lmS9WISbdO9L1Z0tVpb3/LJOXRk/rxkpp3ss4/ZoC91Fak0TD/bKmS48K8KWsb7s3DiMPONGuSJCKtJzAnDHFrr6XN/n+5HZS4xor6AFkH8UtZlBZD03o76i8dCORc/UBhuVfzmjICQbLUAzvu5M8I/4hR21ev9wmaShTNSe8FAjMWV/k2nAmLfDJGNRvoYAqOdGwxij3CviU2ArnIboqbrgu9lA1TNTM55OgAKFOphlHp0tGlfa0WOPtMdB5oYSvpIdnJ1O7+WrnNRcaAl374=" 19 | } 20 | -------------------------------------------------------------------------------- /test/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.where(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.processor = params[:processor] 17 | current_user.card_token = params[:card_token] 18 | subscription = current_user.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 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.where(processor: :stripe).find(params[:id]) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/pay_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Test < ActiveSupport::TestCase 4 | test "truth" do 5 | assert_kind_of Module, Pay 6 | end 7 | 8 | test "default chargeable class is Charge" do 9 | assert Pay.chargeable_class, "Pay::Charge" 10 | end 11 | 12 | test "default chargeable table is charges" do 13 | assert Pay.chargeable_table, "charges" 14 | end 15 | 16 | test "default automount_routes is true" do 17 | assert Pay.automount_routes, true 18 | end 19 | 20 | test "default routes_path is /pay" do 21 | assert Pay.routes_path, "/pay" 22 | end 23 | 24 | test "can set business name" do 25 | assert Pay.respond_to?(:business_name) 26 | assert Pay.respond_to?(:business_name=) 27 | end 28 | 29 | test "can set business address" do 30 | assert Pay.respond_to?(:business_address) 31 | assert Pay.respond_to?(:business_address=) 32 | end 33 | 34 | test "can set application name" do 35 | assert Pay.respond_to?(:application_name) 36 | assert Pay.respond_to?(:application_name=) 37 | end 38 | 39 | test "can set support email" do 40 | assert Pay.respond_to?(:support_email) 41 | assert Pay.respond_to?(:support_email=) 42 | end 43 | 44 | test "can set default product name" do 45 | assert Pay.respond_to?(:default_product_name) 46 | assert Pay.respond_to?(:default_product_name=) 47 | end 48 | 49 | test "can set default plan name" do 50 | assert Pay.respond_to?(:default_plan_name) 51 | assert Pay.respond_to?(:default_plan_name=) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /docs/fake_processor.md: -------------------------------------------------------------------------------- 1 | # Fake Payment Processor 2 | 3 | The fake payment processor is useful for: 4 | 5 | * Testing 6 | * Free subscriptions & charges for users like your team, friends, etc 7 | 8 | ## Usage 9 | 10 | Simply assign `processor: :fake_processor, processor_id: rand(1_000_000), pay_fake_processor_allowed: true` to your user. 11 | 12 | ```ruby 13 | user = User.create!( 14 | email: "gob@bluth.com", 15 | processor: :fake_processor, 16 | processor_id: rand(1_000_000), 17 | pay_fake_processor_allowed: true 18 | ) 19 | 20 | user.charge(25_00) 21 | user.subscribe("default") 22 | ``` 23 | 24 | ## Security 25 | 26 | You don't want malicious users using the fake processor to give themselves free access to your products. 27 | 28 | Pay provides a virtual attribute and validation to ensure the fake processor is only assigned when explicitly allowed. 29 | 30 | ```ruby 31 | # Inside Pay::Billable 32 | attribute :pay_fake_processor_allowed, :boolean, default: false 33 | 34 | validate :pay_fake_processor_allowed 35 | 36 | def pay_fake_processor_is_allowed 37 | return unless processor == "fake_processor" 38 | errors.add(:processor, "must be a valid payment processor") unless pay_fake_processor_allowed? 39 | end 40 | ``` 41 | 42 | `pay_fake_processor_allowed` must be set to `true` before saving. This attribute should *not* included in your permitted_params. 43 | 44 | The validation checks if this attribute is enabled and raises a validation error if not. This prevents malicious uses from submitting `user[processor]=fake_processor` in a form. 45 | -------------------------------------------------------------------------------- /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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/payment_method.updated.json"), object_class: OpenStruct) 7 | end 8 | 9 | test "update_card_from stripe is called upon customer update" do 10 | user = User.create!( 11 | email: "gob@bluth.com", 12 | processor: :stripe, 13 | processor_id: @event.data.object.customer 14 | ) 15 | user.subscriptions.create!( 16 | processor: :stripe, 17 | processor_id: "sub_someid", 18 | name: "default", 19 | processor_plan: "some-plan", 20 | status: "active" 21 | ) 22 | 23 | Pay::Stripe::Billable.any_instance.expects(:sync_card_from_stripe) 24 | Pay::Stripe::Webhooks::PaymentMethodUpdated.new.call(@event) 25 | end 26 | 27 | test "update_card_from stripe is not called if user can't be found" do 28 | user = User.create!( 29 | email: "gob@bluth.com", 30 | processor: :stripe, 31 | processor_id: "does-not-exist" 32 | ) 33 | user.subscriptions.create!( 34 | processor: :stripe, 35 | processor_id: "sub_someid", 36 | name: "default", 37 | processor_plan: "some-plan", 38 | status: "active" 39 | ) 40 | 41 | Pay::Stripe::Billable.any_instance.expects(:sync_card_from_stripe).never 42 | Pay::Stripe::Webhooks::PaymentMethodUpdated.new.call(@event) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/pay/webhooks/delegator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::WebhookDelegatorTest < ActiveSupport::TestCase 4 | class TestEventProcessor 5 | attr_accessor :success 6 | 7 | def call(event) 8 | @success = true 9 | end 10 | end 11 | 12 | setup do 13 | @delegator = Pay::Webhooks::Delegator.new 14 | end 15 | 16 | test "pay has a default delegator" do 17 | assert_not_nil Pay::Webhooks.delegator 18 | end 19 | 20 | test "subscribe includes namespace" do 21 | delegator.subscribe "stripe.test_event", ->(event) {} 22 | assert delegator.backend.notifier.listening?("pay.stripe.test_event") 23 | end 24 | 25 | test "instruments events" do 26 | success = nil 27 | 28 | delegator.subscribe "stripe.test_event" do |event| 29 | success = true 30 | end 31 | 32 | delegator.instrument event: {}, type: "stripe.test_event" 33 | assert success 34 | end 35 | 36 | test "can subscribe with class" do 37 | processor = TestEventProcessor.new 38 | delegator.subscribe "stripe.test_event", processor 39 | delegator.instrument event: {}, type: "stripe.test_event" 40 | assert processor.success 41 | end 42 | 43 | test "can unsubscribe" do 44 | delegator.subscribe "stripe.test_event", ->(event) {} 45 | assert delegator.backend.notifier.listening?("pay.stripe.test_event") 46 | delegator.unsubscribe "stripe.test_event" 47 | assert delegator.backend.notifier.listening?("pay.stripe.test_event") 48 | end 49 | 50 | private 51 | 52 | attr_reader :delegator 53 | end 54 | -------------------------------------------------------------------------------- /lib/pay/paddle/webhooks/subscription_updated.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 3 | module Webhooks 4 | class SubscriptionUpdated 5 | def call(event) 6 | subscription = Pay.subscription_model.find_by(processor: :paddle, processor_id: event["subscription_id"]) 7 | 8 | return if subscription.nil? 9 | 10 | case event["status"] 11 | when "deleted" 12 | subscription.status = "canceled" 13 | subscription.ends_at = Time.zone.parse(event["next_bill_date"]) || Time.zone.now if subscription.ends_at.blank? 14 | when "trialing" 15 | subscription.status = "trialing" 16 | subscription.trial_ends_at = Time.zone.parse(event["next_bill_date"]) 17 | when "active" 18 | subscription.status = "active" 19 | subscription.paddle_paused_from = Time.zone.parse(event["paused_from"]) if event["paused_from"].present? 20 | else 21 | subscription.status = event["status"] 22 | end 23 | 24 | subscription.quantity = event["new_quantity"] 25 | subscription.processor_plan = event["subscription_plan_id"] 26 | subscription.paddle_update_url = event["update_url"] 27 | subscription.paddle_cancel_url = event["cancel_url"] 28 | 29 | # If user was on trial, their subscription ends at the end of the trial 30 | subscription.ends_at = subscription.trial_ends_at if subscription.on_trial? 31 | 32 | subscription.save! 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/pay/paddle/webhooks/subscription_payment_succeeded.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Paddle 3 | module Webhooks 4 | class SubscriptionPaymentSucceeded 5 | def call(event) 6 | billable = Pay.find_billable(processor: :paddle, processor_id: event["user_id"]) 7 | return unless billable.present? 8 | return if billable.charges.where(processor_id: event["subscription_payment_id"]).any? 9 | 10 | charge = create_charge(billable, event) 11 | notify_user(billable, charge) 12 | end 13 | 14 | def create_charge(user, event) 15 | charge = user.charges.find_or_initialize_by( 16 | processor: :paddle, 17 | processor_id: event["subscription_payment_id"] 18 | ) 19 | 20 | params = { 21 | amount: Integer(event["sale_gross"].to_f * 100), 22 | card_type: event["payment_method"], 23 | paddle_receipt_url: event["receipt_url"], 24 | created_at: Time.zone.parse(event["event_time"]) 25 | } 26 | 27 | payment_information = Pay::Paddle::Billable.new(user).payment_information(event["subscription_id"]) 28 | 29 | charge.update(params.merge(payment_information)) 30 | user.update(payment_information) 31 | 32 | charge 33 | end 34 | 35 | def notify_user(billable, charge) 36 | if Pay.send_emails && charge.respond_to?(:receipt) 37 | Pay::UserMailer.with(billable: billable, charge: charge).receipt.deliver_later 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/controllers/braintree_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "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 | -------------------------------------------------------------------------------- /app/models/pay/charge.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | class Charge < Pay::ApplicationRecord 3 | self.table_name = Pay.chargeable_table 4 | 5 | # Only serialize for non-json columns 6 | serialize :data unless json_column?("data") 7 | 8 | # Associations 9 | belongs_to :owner, polymorphic: true 10 | 11 | # Scopes 12 | scope :sorted, -> { order(created_at: :desc) } 13 | default_scope -> { sorted } 14 | 15 | # Validations 16 | validates :amount, presence: true 17 | validates :processor, presence: true 18 | validates :processor_id, presence: true 19 | validates :card_type, presence: true 20 | 21 | store_accessor :data, :paddle_receipt_url 22 | 23 | # Helpers for payment processors 24 | %w[braintree stripe paddle fake_processor].each do |processor_name| 25 | define_method "#{processor_name}?" do 26 | processor == processor_name 27 | end 28 | 29 | scope processor_name, -> { where(processor: processor_name) } 30 | end 31 | 32 | def payment_processor 33 | @payment_processor ||= payment_processor_for(processor).new(self) 34 | end 35 | 36 | def payment_processor_for(name) 37 | "Pay::#{name.to_s.classify}::Charge".constantize 38 | end 39 | 40 | def processor_charge 41 | payment_processor.charge 42 | end 43 | 44 | def refund!(refund_amount = nil) 45 | refund_amount ||= amount 46 | payment_processor.refund!(refund_amount) 47 | end 48 | 49 | def charged_to 50 | "#{card_type} (**** **** **** #{card_last4})" 51 | end 52 | 53 | def paypal? 54 | braintree? && card_type == "PayPal" 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/controllers/pay/webhooks/stripe_controller.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Webhooks 3 | class StripeController < Pay::ApplicationController 4 | if Rails.application.config.action_controller.default_protect_from_forgery 5 | skip_before_action :verify_authenticity_token 6 | end 7 | 8 | def create 9 | delegate_event(verified_event) 10 | head :ok 11 | rescue ::Stripe::SignatureVerificationError => e 12 | log_error(e) 13 | head :bad_request 14 | end 15 | 16 | private 17 | 18 | def delegate_event(event) 19 | Pay::Webhooks.instrument type: "stripe.#{event.type}", event: event 20 | end 21 | 22 | def verified_event 23 | payload = request.body.read 24 | signature = request.headers["Stripe-Signature"] 25 | possible_secrets = secrets(payload, signature) 26 | 27 | possible_secrets.each_with_index do |secret, i| 28 | return ::Stripe::Webhook.construct_event(payload, signature, secret.to_s) 29 | rescue ::Stripe::SignatureVerificationError 30 | raise if i == possible_secrets.length - 1 31 | next 32 | end 33 | end 34 | 35 | def secrets(payload, signature) 36 | secret = Pay::Stripe.signing_secret 37 | return Array.wrap(secret) if secret 38 | raise ::Stripe::SignatureVerificationError.new("Cannot verify signature without a Stripe signing secret", signature, http_body: payload) 39 | end 40 | 41 | def log_error(e) 42 | logger.error e.message 43 | e.backtrace.each { |line| logger.error " #{line}" } 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/pay/webhooks/delegator.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Webhooks 3 | class Delegator 4 | attr_reader :backend 5 | 6 | def initialize 7 | @backend = ActiveSupport::Notifications 8 | end 9 | 10 | # Configure DSL 11 | def configure(&block) 12 | raise ArgumentError, "must provide a block" unless block 13 | block.arity.zero? ? instance_eval(&block) : yield(self) 14 | end 15 | 16 | # Subscribe to specific events 17 | def subscribe(name, callable = nil, &block) 18 | callable ||= block 19 | backend.subscribe to_regexp(name), NotificationAdapter.new(callable) 20 | end 21 | 22 | # Listen to all events 23 | def all(callable = nil, &block) 24 | callable ||= block 25 | subscribe nil, callable 26 | end 27 | 28 | # Unsubscribe 29 | def unsubscribe(name) 30 | backend.unsubscribe name 31 | end 32 | 33 | # Called to process an event 34 | def instrument(event:, type:) 35 | backend.instrument name_with_namespace(type), event 36 | end 37 | 38 | # Strips down to event data only 39 | class NotificationAdapter 40 | def initialize(subscriber) 41 | @subscriber = subscriber 42 | end 43 | 44 | def call(*args) 45 | payload = args.last 46 | @subscriber.call(payload) 47 | end 48 | end 49 | 50 | private 51 | 52 | def to_regexp(name) 53 | %r{^#{Regexp.escape name_with_namespace(name)}} 54 | end 55 | 56 | def name_with_namespace(name, delimiter: ".") 57 | [:pay, name].join(delimiter) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /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 = OpenStruct.new 6 | @event.data = JSON.parse(File.read("test/support/fixtures/stripe/charge_succeeded_event.json"), object_class: OpenStruct) 7 | end 8 | 9 | test "a charge is created" do 10 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: @event.data.object.customer) 11 | 12 | assert_difference "Pay.charge_model.count" do 13 | Pay::Stripe::Webhooks::ChargeSucceeded.new.call(@event) 14 | end 15 | 16 | charge = Pay.charge_model.last 17 | assert_equal 500, charge.amount 18 | assert_equal "4444", charge.card_last4 19 | assert_equal "Visa", charge.card_type 20 | assert_equal "1", charge.card_exp_month 21 | assert_equal "2019", charge.card_exp_year 22 | end 23 | 24 | test "a charge isn't created if no corresponding user can be found" do 25 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: "does-not-exist") 26 | 27 | assert_no_difference "Pay.charge_model.count" do 28 | Pay::Stripe::Webhooks::ChargeSucceeded.new.call(@event) 29 | end 30 | end 31 | 32 | test "a charge isn't created if it already exists" do 33 | @user = User.create!(email: "gob@bluth.com", processor: :stripe, processor_id: @event.data.object.customer) 34 | 35 | @user.charges.create!(amount: 100, processor: :stripe, processor_id: "ch_chargeid", card_type: "Visa", card_exp_month: 1, card_exp_year: 2019, card_last4: "4444") 36 | 37 | assert_no_difference "Pay.charge_model.count" do 38 | Pay::Stripe::Webhooks::ChargeSucceeded.new.call(@event) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /lib/pay/fake_processor/billable.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module FakeProcessor 3 | class Billable 4 | attr_reader :billable 5 | 6 | delegate :processor_id, 7 | :processor_id?, 8 | :email, 9 | :customer_name, 10 | :card_token, 11 | to: :billable 12 | 13 | def initialize(billable) 14 | @billable = billable 15 | end 16 | 17 | def customer 18 | billable 19 | end 20 | 21 | def charge(amount, options = {}) 22 | billable.charges.create( 23 | processor: :fake_processor, 24 | processor_id: rand(100_000_000), 25 | amount: amount, 26 | card_type: :fake, 27 | card_last4: 1234, 28 | card_exp_month: Date.today.month, 29 | card_exp_year: Date.today.year 30 | ) 31 | end 32 | 33 | def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) 34 | subscription = OpenStruct.new id: rand(1_000_000) 35 | billable.create_pay_subscription(subscription, :fake_processor, name, plan, status: :active, quantity: options.fetch(:quantity, 1)) 36 | end 37 | 38 | def update_card(payment_method_id) 39 | billable.update( 40 | card_type: :fake, 41 | card_last4: 1234, 42 | card_exp_month: Date.today.month, 43 | card_exp_year: Date.today.year 44 | ) 45 | end 46 | 47 | def update_email! 48 | # pass 49 | end 50 | 51 | def processor_subscription(subscription_id, options = {}) 52 | billable.subscriptions.find_by(processor: :fake_processor, processor_id: subscription_id) 53 | end 54 | 55 | def trial_end_date(subscription) 56 | Date.today 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/subscription_created.json: -------------------------------------------------------------------------------- 1 | { 2 | "alert_id": "585171988", 3 | "alert_name": "subscription_created", 4 | "cancel_url": "https://checkout.paddle.com/subscription/cancel?user=5&subscription=1&hash=a1f8c289b995bba3df34d38a0671d714d439d017", 5 | "checkout_id": "4-779e7566d0d342e-42367a752f", 6 | "currency": "GBP", 7 | "email": "kjakubowski@example.net", 8 | "event_time": "2020-11-16 16:40:31", 9 | "linked_subscriptions": "5, 3, 6", 10 | "marketing_consent": "1", 11 | "next_bill_date": "2020-12-08", 12 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}", 13 | "quantity": "97", 14 | "source": "Trial", 15 | "status": "trialing", 16 | "subscription_id": "1", 17 | "subscription_plan_id": "4", 18 | "unit_price": "unit_price", 19 | "update_url": "https://checkout.paddle.com/subscription/update?user=6&subscription=1&hash=346cadb27c6146bd13d51228d1c4f34e3c6d954c", 20 | "user_id": "4", 21 | "p_signature": "SgjMh3AjXvcktlN016cOpfwsyFjzXb2e+BKwAxCg5zsgiC7posU0KqMEztS4h/nyTmBvnnhjzoQR4BxREktrsYx9QZ0goTHoWMpFhkYiA5ErfKw43pE0X7br7WZlEngs0plMZmXguVDLJoIaasj0w7FI9rlDRjXzdlpO/AAj2leT7byBXPpwxe1iv2boyRaGoYN81qIHAdRZOgjXAGA28CBBKaE8mODmNvizyteZDLOwjEeumzX+FWTY5TpjIxRAVjpwEHj48YtyJNbT7GGsLnW7c2wLkkRja7roENR4B28dr2I0V4gxWphPEKcYviiDG1jTum2RrKu76CbpiWxrkdZ3OzrdXjqGh6tQuorkMRdXIFVnyhL+sNP6LHxSbLSG6TYt6PxvWE4GUx9FoIftiQI0iFcUfkizc1DnZYm5lnzcxSUkNHrMNHItL6TJV9IXALnaTXvqXlly6LMN8wZKrzzWP1Ca2luu9w8IOVXWJ4NGU36pRGrfRL9MxykVx6hAL020oo9IWKDJc+udSP8lQDd0QyDCHWe0sX6Da2NHGXhlb1n7svNBQ1W7m5rr76Xk9c4mkddYtfJzEkg15n/EwCm2uqAAAq75PSSvKwmCTtT1pwNStBTklrUOBMBokfp2t2q4fcQbQQ3OpT325bstTj+ES8A6wpZCtHMoH/+nD/I=" 22 | } 23 | -------------------------------------------------------------------------------- /docs/adding_a_payment_processor.md: -------------------------------------------------------------------------------- 1 | # Adding a Payment Processor to Pay 2 | 3 | Each payment processor has different needs for webhooks. Rather than relying on external gems, 4 | Pay handles webhooks internally. 5 | 6 | ## Webhook Controller 7 | 8 | Each payment processer can define it's own controller for processing any required webhooks. 9 | 10 | For example, `stripe` has [app/controllers/pay/webhooks/stripe_controller.rb](../app/controllers/pay/webhooks/stripe_controller.rb) 11 | 12 | See also [config/routes.rb](../config/routes.rb) for defining routes. 13 | 14 | The webhook controller is responsible for verifying the webhook payload for authenticity and then sending to the Pay Webhook Delegator 15 | 16 | ### Pay Webhook Delegator 17 | 18 | The Webhook Delegator is responsible for taking an event type and sending it for processing. 19 | 20 | It uses [ActiveSupport::Notifications](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) to subscribe and instrument events. 21 | 22 | ```ruby 23 | Pay::Webhooks.configure do |events| 24 | events.subscribe "stripe.charge.succeeded", Pay::Stripe::Webhooks::ChargeSucceeded.new 25 | end 26 | 27 | module Pay 28 | module Stripe 29 | module Webhooks 30 | class ChargeSucceeded 31 | def call(event) 32 | # processing goes here 33 | end 34 | end 35 | end 36 | end 37 | end 38 | ``` 39 | 40 | For example, when a `stripe.charge.succeeded` event gets processed, the webhook delegator sends the event to any classes that are subscribed to the event type. 41 | 42 | Internally, these events are automatically prefaced with the `pay` namespace so they don't conflict with other events. `stripe.charge.succeeded` is internally routed as `pay.stripe.charge.succeeded`. Payment processors should _not_ preface with `pay.` as it is automatically added. 43 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | successful: 3 | header: Payment Successful 4 | description: This payment was already successfully confirmed. 5 | cancelled: 6 | header: Payment Cancelled 7 | description: This payment was cancelled. 8 | requires_action: 9 | header: Confirm your %{amount} payment 10 | description: Extra confirmation is needed to process your payment. Please confirm your payment by filling out your payment details below. 11 | full_name: Full name 12 | card: Card 13 | button: Pay %{amount} 14 | name_missing: Please provide your name. 15 | success: The payment was successful. 16 | all_rights_reserved: All rights reserved. 17 | back: Go back 18 | receipt: 19 | date: Date 20 | account_billed: Account Billed 21 | product: Product 22 | amount: Amount 23 | charged_to: Charged to 24 | additional_info: Additional Info 25 | errors: 26 | action_required: "This payment attempt failed because additional action is required before it can be completed." 27 | invalid_payment: "This payment attempt failed because of an invalid payment method." 28 | email_required: "Email is required to create a customer" 29 | no_processor: "No payment processor selected. Make sure to set the %{class_name}'s `processor` attribute to either 'stripe' or 'braintree'." 30 | braintree: 31 | authorization: "Either the data you submitted is malformed and does not match the API or the API key you used may not be authorized to perform this action." 32 | 33 | pay: 34 | user_mailer: 35 | receipt: 36 | subject: "Payment receipt" 37 | refund: 38 | subject: "Payment refunded" 39 | subscription_renewing: 40 | subject: "Your upcoming subscription renewal" 41 | payment_action_required: 42 | subject: "Confirm your payment" 43 | -------------------------------------------------------------------------------- /test/pay/paddle/webhooks/subscription_created_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::Webhooks::SubscriptionCreatedTest < ActiveSupport::TestCase 4 | setup do 5 | @data = JSON.parse(File.read("test/support/fixtures/paddle/subscription_created.json")) 6 | @user = User.create!(email: "gob@bluth.com") 7 | end 8 | 9 | test "paddle passthrough" do 10 | passthrough = Pay::Paddle.passthrough(owner: @user, foo: :bar) 11 | parsed = JSON.parse(passthrough) 12 | assert_equal "bar", parsed["foo"] 13 | assert_equal @user, GlobalID::Locator.locate_signed(parsed["owner_sgid"]) 14 | end 15 | 16 | test "a subscription is created" do 17 | assert_difference "Pay.subscription_model.count" do 18 | @data["passthrough"] = Pay::Paddle.passthrough(owner: @user) 19 | Pay::Paddle::Webhooks::SubscriptionCreated.new.call(@data) 20 | end 21 | 22 | assert_equal "paddle", @user.reload.processor 23 | assert_equal @data["user_id"], @user.reload.processor_id 24 | 25 | subscription = Pay.subscription_model.last 26 | assert_equal @data["quantity"].to_i, subscription.quantity 27 | assert_equal @data["subscription_plan_id"], subscription.processor_plan 28 | assert_equal @data["update_url"], subscription.paddle_update_url 29 | assert_equal @data["cancel_url"], subscription.paddle_cancel_url 30 | assert_equal Time.zone.parse(@data["next_bill_date"]), subscription.trial_ends_at 31 | assert_nil subscription.ends_at 32 | end 33 | 34 | test "a subscription isn't created if no corresponding owner can be found" do 35 | @user = User.create!(email: "gob@bluth.com") 36 | @data["passthrough"] = "does-not-exist" 37 | 38 | assert_no_difference "Pay.subscription_model.count" do 39 | Pay::Paddle::Webhooks::SubscriptionCreated.new.call(@data) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /lib/generators/active_record/pay_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/active_record" 4 | require "generators/pay/orm_helpers" 5 | 6 | module ActiveRecord 7 | module Generators 8 | class PayGenerator < ActiveRecord::Generators::Base 9 | include Pay::Generators::OrmHelpers 10 | source_root File.expand_path("../templates", __FILE__) 11 | 12 | def copy_pay_billable_migration 13 | if (behavior == :invoke && model_exists?) || (behavior == :revoke && migration_exists?(table_name)) 14 | migration_template "migration.rb", "#{migration_path}/add_pay_billable_to_#{table_name}.rb", migration_version: migration_version 15 | end 16 | # TODO: Throw error here that model should already exist if it doesn't 17 | end 18 | 19 | def inject_pay_billable_content 20 | content = model_contents 21 | 22 | class_path = if namespaced? 23 | class_name.to_s.split("::") 24 | else 25 | [class_name] 26 | end 27 | 28 | indent_depth = class_path.size - 1 29 | content = content.split("\n").map { |line| " " * indent_depth + line }.join("\n") << "\n" 30 | 31 | inject_into_class(model_path, class_path.last, content) if model_exists? 32 | end 33 | 34 | def migration_data 35 | <= 5 49 | end 50 | 51 | def migration_version 52 | if rails5_and_up? 53 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = false 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | "Cache-Control" => "public, max-age=3600", 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | config.action_mailer.perform_caching = false 31 | 32 | # Tell Action Mailer not to deliver emails to the real world. 33 | # The :test delivery method accumulates sent emails in the 34 | # ActionMailer::Base.deliveries array. 35 | config.action_mailer.delivery_method = :test 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/subscription_payment_refunded.json: -------------------------------------------------------------------------------- 1 | { 2 | "alert_id": "39297549", 3 | "alert_name": "subscription_payment_refunded", 4 | "amount": "671.87", 5 | "balance_currency": "EUR", 6 | "balance_earnings_decrease": "0", 7 | "balance_fee_refund": "0.35", 8 | "balance_gross_refund": "0.68", 9 | "balance_tax_refund": "0.52", 10 | "checkout_id": "9-3eafeae89135527-aca34a910b", 11 | "currency": "USD", 12 | "earnings_decrease": "0.83", 13 | "email": "glindgren@example.org", 14 | "event_time": "2020-11-16 16:52:45", 15 | "fee_refund": "0.76", 16 | "gross_refund": "0.17", 17 | "initial_payment": "true", 18 | "instalments": "2", 19 | "marketing_consent": "", 20 | "order_id": "4", 21 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}", 22 | "quantity": "28", 23 | "refund_reason": "refund_reason", 24 | "refund_type": "partial", 25 | "status": "active", 26 | "subscription_id": "7", 27 | "subscription_payment_id": "1", 28 | "subscription_plan_id": "6", 29 | "tax_refund": "0.04", 30 | "unit_price": "unit_price", 31 | "user_id": "7", 32 | "p_signature": "rGXTK+5AM9/MXo0/YQUPKTxT1UfPOdlmTeBfRu5KFFdmXgTAKs6lkiFujnGUdXNr9n58gQ87BhITSnzAYxrHQZPStzQ810S8r/ES0xKeObueDLt/0Pf8NrV5w9C6m26ZMVo/2NqkH2qx23DJRfJ02c7Bm8LIEuT3bLvJsgIbxHyOh50GooAyg9vn/k6XAelbbt8uOoSEte5kU12THautegClljemjl7iASk1QzuAGc7kYFvoj92zZp+hed/5xhkEveKNR7PAh8zhq6u+yyXaySg4M/41S6dFLrfyAf+tYiClewGN1B3o0ZsXK6fqNPQ4ObCO8QAOm9a69oDAQHkm14SuhNuVYZiMzxwaahGfT0sUs0zT3cFsSjZSChNt+LxN30F51FKYvwz+NF1edQWH6pFKC0RXRasKUhbY5/4lUNpxWt/fF9PCQTqNDnXIOl7NlTW4v+SZ5WkjMKkzDk9nM97jQHeowCdCwLxgaraITyjpY8bUf5KEAWZK890BwQTM/gUrv/IhBTgtZdU9AF4C9Ql/p0I4KW7kHQIpoll1ijEScS6PELNftxEmsB54UK2G8N5YudGaZAzIPDDPaG/HEu4JTVAy5dlxi8/LkkRQ02UAzwIOQ28e8YdUSwwUSTFE976coT9AK+OyYZimdsfDCZgKq2Hqc2IuZRTVBMc1wsg=" 33 | } 34 | -------------------------------------------------------------------------------- /test/dummy/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false 60 | } 61 | ], 62 | [ 63 | '@babel/plugin-transform-regenerator', 64 | { 65 | async: false 66 | } 67 | ] 68 | ].filter(Boolean) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/pay/stripe/webhooks/subscription_created.rb: -------------------------------------------------------------------------------- 1 | module Pay 2 | module Stripe 3 | module Webhooks 4 | class SubscriptionCreated 5 | def call(event) 6 | object = event.data.object 7 | 8 | # We may already have the subscription in the database, so we can update that record 9 | subscription = Pay.subscription_model.find_by(processor: :stripe, processor_id: object.id) 10 | 11 | # Create the subscription in the database if we don't have it already 12 | if subscription.nil? 13 | # The customer should already be in the database 14 | owner = Pay.find_billable(processor: :stripe, processor_id: object.customer) 15 | 16 | if owner.nil? 17 | Rails.logger.error("[Pay] Unable to find Pay::Billable with processor: :stripe and processor_id: '#{object.customer}'. Searched these models: #{Pay.billable_models.join(", ")}") 18 | return 19 | end 20 | 21 | subscription = Pay.subscription_model.new(name: Pay.default_product_name, owner: owner, processor: :stripe, processor_id: object.id) 22 | end 23 | 24 | subscription.quantity = object.quantity 25 | subscription.status = object.status 26 | subscription.processor_plan = object.plan.id 27 | subscription.trial_ends_at = Time.at(object.trial_end) if object.trial_end.present? 28 | 29 | # If user was on trial, their subscription ends at the end of the trial 30 | subscription.ends_at = if object.cancel_at_period_end && subscription.on_trial? 31 | subscription.trial_ends_at 32 | 33 | # User wasn't on trial, so subscription ends at period end 34 | elsif object.cancel_at_period_end 35 | Time.at(object.current_period_end) 36 | 37 | # Subscription isn't marked to cancel at period end 38 | end 39 | 40 | subscription.save! 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /docs/stripe.md: -------------------------------------------------------------------------------- 1 | # Using Pay with Stripe 2 | 3 | Stripe has multiple integrations: 4 | 5 | * Stripe Checkout - Hosted pages for payments 6 | 7 | ## Stripe Checkout 8 | 9 | [Stripe Checkout](https://stripe.com/docs/payments/checkout) allows you to simply redirect to Stripe for handling payments. The main benefit is that it's super fast to setup payments in your application, they're SCA compatible, and they will get improved automatically by Stripe. 10 | 11 | ![stripe checkout example](https://i.imgur.com/nFsCBCK.gif) 12 | 13 | ### How to use Stripe Checkout with Pay 14 | 15 | 1. Create a checkout session 16 | 17 | Choose the checkout button mode you need and pass any required arguments. Read the [Stripe Checkout Session API docs](https://stripe.com/docs/api/checkout/sessions/create) to see what options are available. 18 | 19 | ```ruby 20 | # Make sure the user's payment processor is Stripe 21 | current_user.processor = :stripe 22 | 23 | # One-time payments 24 | @checkout_session = current_user.payment_processor.checkout(mode: "payment", line_items: "price_1ILVZaKXBGcbgpbZQ26kgXWG") 25 | 26 | # Subscriptions 27 | @checkout_session = current_user.payment_processor.checkout(mode: "subscription", line_items: "default") 28 | 29 | # Setup a new card for future use 30 | @subscription = current_user.payment_processor.checkout(mode: "setup") 31 | ``` 32 | 33 | 2. Render the button 34 | 35 | ```erb 36 | <%= render partial: "pay/stripe/checkout_button", locals: { session: @checkout_session, title: "Checkout" } %> 37 | ``` 38 | 39 | 3. Link to the Customer Billing Portal 40 | 41 | Customers will want to update their payment method, subscription, etc. This can be done with the Customer Billing Portal. It works the same as the other Stripe Checkout pages. 42 | 43 | First, create a session in your controller: 44 | 45 | ```ruby 46 | @portal_session = current_user.payment_processor.billing_portal 47 | ``` 48 | 49 | Then link to it in your view 50 | 51 | ```erb 52 | <%= link_to "Billing Portal", @portal_session.url %> 53 | ``` 54 | 55 | That's it! 56 | -------------------------------------------------------------------------------- /test/pay/paddle/charge_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::Charge::Test < ActiveSupport::TestCase 4 | setup do 5 | @billable = User.create!(email: "gob@bluth.com", processor: :paddle, processor_id: "17368056") 6 | @billable.subscriptions.create!( 7 | processor: :paddle, 8 | processor_id: "3576390", 9 | name: "default", 10 | processor_plan: "some-plan", 11 | status: "active" 12 | ) 13 | end 14 | 15 | test "paddle can get paddle charge" do 16 | charge = @billable.charges.create!( 17 | processor: :paddle, 18 | processor_id: "11018517", 19 | amount: 119, 20 | card_type: "card", 21 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c", 22 | created_at: Time.zone.now 23 | ) 24 | paddle_charge = charge.processor_charge 25 | assert_equal charge.processor_id, paddle_charge[:id].to_s 26 | end 27 | 28 | test "paddle can fully refund a transaction" do 29 | charge = @billable.charges.create!( 30 | processor: :paddle, 31 | processor_id: "11018517", 32 | amount: 119, 33 | card_type: "card", 34 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c", 35 | created_at: Time.zone.now 36 | ) 37 | 38 | charge.refund! 39 | assert_equal 119, charge.amount_refunded 40 | end 41 | 42 | test "paddle cannot refund a transaction without payment" do 43 | charge = @billable.charges.create!( 44 | processor: :paddle, 45 | processor_id: "does-not-exist", 46 | amount: 119, 47 | card_type: "card", 48 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c", 49 | created_at: Time.zone.now 50 | ) 51 | 52 | assert_raises(Pay::Error) { charge.refund! } 53 | end 54 | 55 | test "you can ask the charge for the paddle type" do 56 | assert Pay::Charge.new(processor: "paddle").paddle? 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/vcr_cassettes/test_user_with_braintree_as_processor.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: put 5 | uri: https://api.sandbox.braintreegateway.com/merchants/zyfwpztymjqdcc5g/customers/ 6 | body: 7 | encoding: UTF-8 8 | string: | 9 | 10 | 11 | johnny@appleseed.com 12 | 13 | 14 | 15 | headers: 16 | Accept-Encoding: 17 | - gzip 18 | Accept: 19 | - application/xml 20 | User-Agent: 21 | - Braintree Ruby Gem 3.2.0 22 | X-Apiversion: 23 | - '6' 24 | Content-Type: 25 | - application/xml 26 | Authorization: 27 | - Basic NXI1OXJyeGhuODlucGM5bjowMGYwZGY3OTMwM2UxMjcwODgxZTVmZWRhNzc4ODkyNw== 28 | response: 29 | status: 30 | code: 404 31 | message: '' 32 | headers: 33 | Date: 34 | - Tue, 16 Feb 2021 03:37:45 GMT 35 | Content-Type: 36 | - application/xml; charset=utf-8 37 | Content-Length: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Xss-Protection: 42 | - 1; mode=block 43 | X-Content-Type-Options: 44 | - nosniff 45 | X-Download-Options: 46 | - noopen 47 | X-Permitted-Cross-Domain-Policies: 48 | - none 49 | Referrer-Policy: 50 | - strict-origin-when-cross-origin 51 | Cache-Control: 52 | - no-cache 53 | X-Runtime: 54 | - '0.004227' 55 | Vary: 56 | - Origin 57 | X-Request-Id: 58 | - 7e559417-8feb-4d1d-824c-84c88c07510b 59 | Content-Security-Policy: 60 | - frame-ancestors 'self' 61 | X-Broxyid: 62 | - 7e559417-8feb-4d1d-824c-84c88c07510b 63 | Strict-Transport-Security: 64 | - max-age=31536000; includeSubDomains 65 | body: 66 | encoding: UTF-8 67 | string: '' 68 | recorded_at: Tue, 16 Feb 2021 03:37:45 GMT 69 | recorded_with: VCR 6.0.0 70 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/subscription_updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "alert_id": "185964151", 3 | "alert_name": "subscription_updated", 4 | "cancel_url": "https://checkout.paddle.com/subscription/cancel?user=4&subscription=7&hash=1a835937b1ba3f65eab36edf2fd1e4e158379589", 5 | "checkout_id": "6-3e32d84e61233cf-6d256382f4", 6 | "currency": "USD", 7 | "email": "little.kasandra@example.com", 8 | "event_time": "2020-11-16 17:02:25", 9 | "linked_subscriptions": "8, 6, 2", 10 | "marketing_consent": "", 11 | "new_price": "new_price", 12 | "new_quantity": "2", 13 | "new_unit_price": "new_unit_price", 14 | "next_bill_date": "2020-12-08", 15 | "old_next_bill_date": "old_next_bill_date", 16 | "old_price": "old_price", 17 | "old_quantity": "old_quantity", 18 | "old_status": "old_status", 19 | "old_subscription_plan_id": "old_subscription_plan_id", 20 | "old_unit_price": "old_unit_price", 21 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}", 22 | "status": "active", 23 | "subscription_id": "2", 24 | "subscription_plan_id": "8", 25 | "update_url": "https://checkout.paddle.com/subscription/update?user=8&subscription=8&hash=388a46e1fd927fe0662e40c9e37b7b8b2983c74b", 26 | "user_id": "4", 27 | "p_signature": "BLslG1ZJTzXtqBPc89AK9RIhT5rscIcFydJmuZh2dw0xqC+XxlAsyEU2QV2OKKJpcO6qPKPh4MZu7y8L8ukb0kAE0d3y/wfXsCY/Y3HMq0gk2pygPzhEKdOoU9qkPngAVxnFxO+B88XZdmEYy9T24vdW0KW4N5oXkkevyKddLISBienljTXo+ncg3PHxIxnfWYeIVufM1yd0fmTfErW31fIhX7piMPg71R6cgMK4nGlK7BvvpkrCpsrVZuCSt0AmvnsB8YmNuAvSeHXz1GNbExNNEy30UGWzGzO48r1r17h4C2RXhRqxkMycLRHETjVxAvaJ7HMglP1izg+EIPr3vv7dORsq316mi9uETemhctO3Ck89bwMjC7lSdihTIdO9KaySeYZ8sYE9mWkoCMcoPEWi2DCuyiQfdcf0iw/BRIQQnH0wJSmo/viaz2ETm2487ZPNjn16BxnB9usA+hCYgobbWy/TKw1A2k0IMK1sPDohdjI5wZ86KD84cpggqkP8uSWt9GySQrQ21p9/xLltwby8iPrApSoTbqU1JdvUqnaqnodfRImuk72fNhz13otNUHgxc3fW/C5fUq5sHFocRDwgn91wE+RfCT20+7R9s0NY32BOZbb+pWqDBGHg/kSO4p2pnliwvhalodQWUquRgXelXokI3uSl0FJdn17kJOs=" 28 | } 29 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join("tmp/caching-dev.txt").exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | "Cache-Control" => "public, max-age=172800", 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Don't care if the mailer can't send. 30 | config.action_mailer.raise_delivery_errors = false 31 | 32 | config.action_mailer.perform_caching = false 33 | 34 | # Print deprecation notices to the Rails logger. 35 | config.active_support.deprecation = :log 36 | 37 | # Raise an error on page load if there are pending migrations. 38 | config.active_record.migration_error = :page_load 39 | 40 | # Debug mode disables concatenation and preprocessing of assets. 41 | # This option may cause significant delays in view rendering with a large 42 | # number of complex assets. 43 | config.assets.debug = true 44 | 45 | # Suppress logger output for asset requests. 46 | config.assets.quiet = true 47 | 48 | # Raises error for missing translations 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | end 55 | -------------------------------------------------------------------------------- /test/mailers/pay/user_mailer_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class UserMailerTest < ActionMailer::TestCase 4 | setup do 5 | @user = User.new(email: "john@example.org", extra_billing_info: "extra billing info") 6 | @charge = @user.charges.new(amount: 100, created_at: Time.zone.now) 7 | end 8 | 9 | test "receipt" do 10 | email = Pay::UserMailer.with(billable: @user, charge: @charge).receipt 11 | 12 | assert_equal [@user.email], email.to 13 | assert_equal I18n.t("pay.user_mailer.receipt.subject"), email.subject 14 | end 15 | 16 | test "attaches refunds to receipt" do 17 | filename = "receipt.pdf" 18 | 19 | receipt = mock("receipt") 20 | receipt.stubs(:render).returns("render content") 21 | receipt.stubs(:length).returns(1024) 22 | 23 | @charge.stubs(:filename).returns(filename) 24 | @charge.stubs(:receipt).returns(receipt) 25 | 26 | email = Pay::UserMailer.with(billable: @user, charge: @charge).receipt 27 | 28 | assert_equal filename, email.attachments.first.filename 29 | end 30 | 31 | test "refund" do 32 | email = Pay::UserMailer.with(billable: @user, charge: @charge).refund 33 | 34 | assert_equal [@user.email], email.to 35 | assert_equal I18n.t("pay.user_mailer.refund.subject"), email.subject 36 | end 37 | 38 | test "subscription_renewing" do 39 | time = Time.current 40 | email = Pay::UserMailer.with(billable: @user, subscription: Pay::Subscription.new, date: time).subscription_renewing 41 | 42 | assert_equal [@user.email], email.to 43 | assert_equal I18n.t("pay.user_mailer.subscription_renewing.subject"), email.subject 44 | assert_includes email.body.decoded, I18n.l(time.to_date, format: :long) 45 | end 46 | 47 | test "payment_action_required" do 48 | email = Pay::UserMailer.with(billable: @user, payment_intent_id: "x", subscription: Pay::Subscription.new).payment_action_required 49 | 50 | assert_equal [@user.email], email.to 51 | assert_equal I18n.t("pay.user_mailer.payment_action_required.subject"), email.subject 52 | assert_includes email.body.decoded, Pay::Engine.instance.routes.url_helpers.payment_path("x") 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/support/fixtures/paddle/subscription_payment_succeeded.json: -------------------------------------------------------------------------------- 1 | { 2 | "alert_id": "1870734833", 3 | "alert_name": "subscription_payment_succeeded", 4 | "balance_currency": "EUR", 5 | "balance_earnings": "953.67", 6 | "balance_fee": "171.97", 7 | "balance_gross": "628.6", 8 | "balance_tax": "329.68", 9 | "checkout_id": "7-26a7d8e37082d4c-9f1aa08d55", 10 | "country": "CA", 11 | "coupon": "Coupon 5", 12 | "currency": "USD", 13 | "customer_name": "customer_name", 14 | "earnings": "341.05", 15 | "email": "lina85@example.com", 16 | "event_time": "2020-11-16 16:49:17", 17 | "fee": "0.95", 18 | "initial_payment": "true", 19 | "instalments": "1", 20 | "marketing_consent": "", 21 | "next_bill_date": "2020-11-30", 22 | "next_payment_amount": "next_payment_amount", 23 | "order_id": "2", 24 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}", 25 | "payment_method": "paypal", 26 | "payment_tax": "0.68", 27 | "plan_name": "Example String", 28 | "quantity": "71", 29 | "receipt_url": "https://my.paddle.com/receipt/3/018e8d58bd4f176-12e624ab4a", 30 | "sale_gross": "215.16", 31 | "status": "past_due", 32 | "subscription_id": "8", 33 | "subscription_payment_id": "9", 34 | "subscription_plan_id": "6", 35 | "unit_price": "unit_price", 36 | "user_id": "9", 37 | "p_signature": "YEeKhxHwbdh/KT+6IKf7vqswmbeLQrTr8zcKqcHZmXKaUf1FFiblCkn4UIMyFO84wa3jHAjbXdd+AxDuq8z9wdgVwfaWAZN9594pyOeGhHcPKf4dGDtbuTvmNqBHU9chVZLpIqFtIRoDe0CUQdMp4ET8KIubvy+L5Xvw5HR1/gSaHeVRe6Ob4b86Wgik7Sg2kAyC/g7Z3G3u0pAnlkZsjE4RhMhlWiwXHJLRTJN7eGXAaBU8NlLQJA0WhOQmVlVxT5N9hpxA3aLDcejTbXT9B1KPQiO0ceK4qSsHfwJJZ9WvGrvTk5k0tX3ppSBmRuWQUGU1CeXKTBJQG1l1zr5S1q+eT1jqMGpz6Ich2id8NBbB/LFQd1+rff+tQ8NugdIszbAnTjlYaQn8rTdAoRArLLtJ39LahQo/dbxNWTOgF+9lAoeq+tsAxLZj/U2TmiPvzuyLjnfqrElBcYyRsg0NQNU8RVEIPjzHJypeQ4/1PAtjASLWk5NeM/kj/jbELF/UwBS/l+4fHOETvVHULiZXvDUyDl3ZGHeESBlLCWEElaiyJ/bf9ca896TJ/zN/iI0ju1QI1GqR7hApRpwTe+Og6uC1SscjeAJBIGodkEtOfbKRCL4BMF9vp3iqqThornOavMg1TBZb/vuW9Ild2gcu+/0no5d2tF2ztK3H6hYelR8=" 38 | } 39 | -------------------------------------------------------------------------------- /test/pay/paddle/billable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Pay::Paddle::Billable::Test < ActiveSupport::TestCase 4 | setup do 5 | @billable = User.create!(email: "gob@bluth.com", processor: :paddle, processor_id: "17368056") 6 | @billable.subscriptions.create!( 7 | processor: :paddle, 8 | processor_id: "3576390", 9 | name: "default", 10 | processor_plan: "some-plan", 11 | status: "active" 12 | ) 13 | end 14 | 15 | test "paddle can create a charge" do 16 | charge = @billable.charge(1000, {charge_name: "Test"}) 17 | assert_equal Pay::Charge, charge.class 18 | assert_equal 1000, charge.amount 19 | end 20 | 21 | test "paddle cannot create a charge without charge_name" do 22 | assert_raises(Pay::Error) { @billable.charge(1000) } 23 | end 24 | 25 | test "retriving a paddle subscription" do 26 | subscription = ::PaddlePay::Subscription::User.list({subscription_id: "3576390"}, {}).try(:first) 27 | assert_equal @billable.processor_subscription("3576390").subscription_id, subscription[:subscription_id] 28 | end 29 | 30 | test "paddle can sync payment information" do 31 | subscription_user = { 32 | subscription_id: 7654321, 33 | plan_id: 123456, 34 | user_id: 12345678, 35 | user_email: "test@example.com", 36 | marketing_consent: false, 37 | update_url: "https://example.com", 38 | cancel_url: "https://example.com", 39 | state: "active", 40 | signup_date: "2020-12-08 07:52:22", 41 | last_payment: {amount: 0, currency: "USD", date: "2020-12-08"}, linked_subscriptions: [], 42 | payment_information: {payment_method: "card", card_type: "visa", last_four_digits: "0020", expiry_date: "06/2022"}, 43 | next_payment: {amount: 0, currency: "USD", date: "2021-01-08"} 44 | } 45 | PaddlePay::Subscription::User.stubs(:list).returns([subscription_user]) 46 | 47 | @billable.payment_processor.sync_payment_information 48 | 49 | assert_equal "visa", @billable.card_type 50 | assert_equal "0020", @billable.card_last4 51 | assert_equal "06", @billable.card_exp_month 52 | assert_equal "2022", @billable.card_exp_year 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum, this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # The code in the `on_worker_boot` will be called if you are using 36 | # clustered mode by specifying a number of `workers`. After each worker 37 | # process is booted this block will be run, if you are using `preload_app!` 38 | # option you will want to use this block to reconnect to any threads 39 | # or connections that may have been created at application boot, Ruby 40 | # cannot share connections between processes. 41 | # 42 | # on_worker_boot do 43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 44 | # end 45 | 46 | # Allow puma to be restarted by `rails restart` command. 47 | plugin :tmp_restart 48 | -------------------------------------------------------------------------------- /test/dummy/config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/javascript 5 | source_entry_path: packs 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | webpack_compile_output: true 10 | 11 | # Additional paths webpack should lookup modules 12 | # ['app/assets', 'engine/foo/app/assets'] 13 | additional_paths: [] 14 | 15 | # Reload manifest.json on all requests so we reload latest compiled packs 16 | cache_manifest: false 17 | 18 | # Extract and emit a css file 19 | extract_css: false 20 | 21 | static_assets_extensions: 22 | - .jpg 23 | - .jpeg 24 | - .png 25 | - .gif 26 | - .tiff 27 | - .ico 28 | - .svg 29 | - .eot 30 | - .otf 31 | - .ttf 32 | - .woff 33 | - .woff2 34 | 35 | extensions: 36 | - .mjs 37 | - .js 38 | - .sass 39 | - .scss 40 | - .css 41 | - .module.sass 42 | - .module.scss 43 | - .module.css 44 | - .png 45 | - .svg 46 | - .gif 47 | - .jpeg 48 | - .jpg 49 | 50 | development: 51 | <<: *default 52 | compile: true 53 | 54 | # Reference: https://webpack.js.org/configuration/dev-server/ 55 | dev_server: 56 | https: false 57 | host: localhost 58 | port: 3035 59 | public: localhost:3035 60 | hmr: false 61 | # Inline should be set to true if using HMR 62 | inline: true 63 | overlay: true 64 | compress: true 65 | disable_host_check: true 66 | use_local_ip: false 67 | quiet: false 68 | pretty: false 69 | headers: 70 | 'Access-Control-Allow-Origin': '*' 71 | watch_options: 72 | ignored: '**/node_modules/**' 73 | 74 | 75 | test: 76 | <<: *default 77 | compile: true 78 | 79 | # Compile test packs to a separate directory 80 | public_output_path: packs-test 81 | 82 | production: 83 | <<: *default 84 | 85 | # Production depends on precompilation of packs prior to booting for performance. 86 | compile: false 87 | 88 | # Extract and emit a css file 89 | extract_css: true 90 | 91 | # Cache manifest.json for performance 92 | cache_manifest: true 93 | --------------------------------------------------------------------------------