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