├── spec ├── dummy │ ├── log │ │ └── .keep │ ├── app │ │ ├── mailers │ │ │ └── .keep │ │ ├── models │ │ │ ├── .keep │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── user.rb │ │ │ ├── owner.rb │ │ │ ├── subscription_plan.rb │ │ │ ├── product.rb │ │ │ └── subscription_plan_without_interval_count.rb │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── stylesheets │ │ │ │ ├── buy.css │ │ │ │ └── application.css │ │ │ └── javascripts │ │ │ │ ├── buy.js │ │ │ │ └── application.js │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── home_controller.rb │ │ │ ├── buy_controller.rb │ │ │ ├── application_controller.rb │ │ │ └── subscribe_controller.rb │ │ ├── helpers │ │ │ ├── buy_helper.rb │ │ │ └── application_helper.rb │ │ └── views │ │ │ ├── subscribe │ │ │ ├── show.html.erb │ │ │ └── index.html.erb │ │ │ ├── home │ │ │ └── index.html.erb │ │ │ ├── layouts │ │ │ └── application.html.erb │ │ │ └── buy │ │ │ └── index.html.erb │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ └── rails │ ├── spec │ │ ├── controllers │ │ │ └── buy_controller_spec.rb │ │ └── factories │ │ │ └── products.rb │ ├── config │ │ ├── initializers │ │ │ ├── cookies_serializer.rb │ │ │ ├── session_store.rb │ │ │ ├── mime_types.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── payola.rb │ │ │ ├── assets.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── wrap_parameters.rb │ │ │ └── inflections.rb │ │ ├── environment.rb │ │ ├── boot.rb │ │ ├── routes.rb │ │ ├── database.yml │ │ ├── locales │ │ │ ├── en.yml │ │ │ └── de.yml │ │ ├── application.rb │ │ ├── secrets.yml │ │ └── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ ├── config.ru │ ├── db │ │ ├── migrate │ │ │ ├── 20141029140518_create_owners.rb │ │ │ ├── 20141204170622_create_users.rb │ │ │ ├── 20141001230848_create_products.rb │ │ │ ├── 20141120170744_create_subscription_plan_without_interval_counts.rb │ │ │ └── 20141105010234_create_subscription_plans.rb │ │ └── schema.rb │ ├── Rakefile │ └── README.rdoc ├── factories │ ├── product.rb │ ├── sales.rb │ ├── payola_coupons.rb │ ├── payola_affiliates.rb │ ├── subscription_plan.rb │ └── payola_subscriptions.rb ├── services │ └── payola │ │ ├── process_sale_spec.rb │ │ ├── sync_subscription_spec.rb │ │ ├── process_subscription_spec.rb │ │ ├── send_mail_spec.rb │ │ ├── update_card_spec.rb │ │ ├── invoice_failed_spec.rb │ │ ├── change_subscription_quantity_spec.rb │ │ ├── cancel_subscription_spec.rb │ │ ├── change_subscription_plan_spec.rb │ │ ├── create_plan_spec.rb │ │ ├── create_subscription_spec.rb │ │ ├── invoice_paid_spec.rb │ │ ├── create_sale_spec.rb │ │ ├── charge_card_spec.rb │ │ └── start_subscription_spec.rb ├── models │ └── payola │ │ ├── stripe_webhook_spec.rb │ │ ├── coupon_spec.rb │ │ ├── subscription_spec.rb │ │ └── sale_spec.rb ├── helpers │ └── payola │ │ └── price_helper_spec.rb ├── concerns │ ├── sellable_spec.rb │ └── plan_spec.rb ├── mailers │ └── payola │ │ ├── admin_mailer_spec.rb │ │ └── receipt_mailer_spec.rb ├── controllers │ └── payola │ │ ├── transactions_controller_spec.rb │ │ └── subscriptions_controller_spec.rb ├── spec_helper.rb ├── worker_spec.rb └── payola_spec.rb ├── app ├── assets │ ├── images │ │ └── payola │ │ │ └── .keep │ ├── javascripts │ │ ├── payola.js │ │ └── payola │ │ │ ├── application.js │ │ │ ├── form.js │ │ │ ├── checkout_button.js │ │ │ ├── subscription_form_twostep.js │ │ │ └── subscription_form_onestep.js │ └── stylesheets │ │ └── payola │ │ └── application.css ├── helpers │ └── payola │ │ ├── application_helper.rb │ │ └── price_helper.rb ├── models │ ├── payola │ │ ├── coupon.rb │ │ ├── affiliate.rb │ │ ├── stripe_webhook.rb │ │ ├── sale.rb │ │ └── subscription.rb │ └── concerns │ │ └── payola │ │ ├── guid_behavior.rb │ │ ├── sellable.rb │ │ └── plan.rb ├── controllers │ ├── payola │ │ ├── application_controller.rb │ │ ├── transactions_controller.rb │ │ └── subscriptions_controller.rb │ └── concerns │ │ └── payola │ │ ├── affiliate_behavior.rb │ │ ├── status_behavior.rb │ │ └── async_behavior.rb ├── services │ └── payola │ │ ├── process_sale.rb │ │ ├── process_subscription.rb │ │ ├── send_mail.rb │ │ ├── sync_subscription.rb │ │ ├── update_subscription.rb │ │ ├── invoice_paid.rb │ │ ├── invoice_failed.rb │ │ ├── cancel_subscription.rb │ │ ├── create_plan.rb │ │ ├── change_subscription_quantity.rb │ │ ├── create_sale.rb │ │ ├── create_subscription.rb │ │ ├── change_subscription_plan.rb │ │ ├── update_card.rb │ │ ├── invoice_behavior.rb │ │ ├── charge_card.rb │ │ └── start_subscription.rb ├── views │ ├── payola │ │ ├── transactions │ │ │ ├── new.html.erb │ │ │ ├── show.html.erb │ │ │ ├── _stripe_header.html.erb │ │ │ ├── iframe.html.erb │ │ │ ├── wait.html.erb │ │ │ ├── _checkout.html.erb │ │ │ └── _form.html.erb │ │ ├── admin_mailer │ │ │ ├── refund.html.erb │ │ │ ├── dispute.html.erb │ │ │ ├── receipt.html.erb │ │ │ └── failure.html.erb │ │ ├── receipt_mailer │ │ │ ├── receipt.text.erb │ │ │ ├── refund.html.erb │ │ │ ├── receipt.html.erb │ │ │ └── receipt_pdf.html.erb │ │ └── subscriptions │ │ │ ├── _change_plan.html.erb │ │ │ └── _cancel.html.erb │ └── layouts │ │ └── payola │ │ └── application.html.erb └── mailers │ └── payola │ ├── admin_mailer.rb │ └── receipt_mailer.rb ├── lib ├── payola-payments.rb ├── payola │ ├── version.rb │ ├── worker │ │ ├── base.rb │ │ ├── sidekiq.rb │ │ ├── sucker_punch.rb │ │ └── active_job.rb │ ├── worker.rb │ └── engine.rb ├── tasks │ └── payola_tasks.rake ├── generators │ └── payola │ │ ├── install_generator.rb │ │ └── templates │ │ └── initializer.rb └── payola.rb ├── config ├── database.yml.ci └── routes.rb ├── circle.yml ├── db └── migrate │ ├── 20141017233304_add_currency_to_payola_sale.rb │ ├── 20141026101628_add_custom_fields_to_payola_sale.rb │ ├── 20141213205847_add_active_to_payola_coupon.rb │ ├── 20141114032013_add_coupon_code_to_payola_subscriptions.rb │ ├── 20141122020755_add_setup_fee_to_payola_subscriptions.rb │ ├── 20141109203101_add_stripe_status_to_payola_subscription.rb │ ├── 20141112024805_add_affiliate_id_to_payola_subscriptions.rb │ ├── 20141114154223_add_signed_custom_fields_to_payola_subscription.rb │ ├── 20141107025420_add_guid_to_payola_subscriptions.rb │ ├── 20141002203725_add_stripe_customer_id_to_sale.rb │ ├── 20141001203541_create_payola_stripe_webhooks.rb │ ├── 20141026144800_rename_custom_fields_to_signed_custom_fields_on_payola_sale.rb │ ├── 20141106034610_add_currency_to_payola_subscriptions.rb │ ├── 20141002013618_create_payola_coupons.rb │ ├── 20141114163841_add_addresses_to_payola_subscription.rb │ ├── 20141029135848_add_owner_to_payola_sale.rb │ ├── 20141002013701_create_payola_affiliates.rb │ ├── 20141105043439_create_payola_subscriptions.rb │ └── 20141001170138_create_payola_sales.rb ├── .gitignore ├── LICENSE ├── bin └── rails ├── Gemfile ├── Rakefile ├── payola.gemspec ├── CHANGELOG.md └── README.md /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/payola/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/payola-payments.rb: -------------------------------------------------------------------------------- 1 | require_relative './payola' 2 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola.js: -------------------------------------------------------------------------------- 1 | //= require_tree './payola' 2 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/buy_helper.rb: -------------------------------------------------------------------------------- 1 | module BuyHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/payola/version.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | VERSION = "1.2.4" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/models/owner.rb: -------------------------------------------------------------------------------- 1 | class Owner < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/payola/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/tasks/payola_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :payola do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/subscription_plan.rb: -------------------------------------------------------------------------------- 1 | class SubscriptionPlan < ActiveRecord::Base 2 | include Payola::Plan 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/spec/controllers/buy_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BuyController do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /config/database.yml.ci: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | database: app_test 4 | pool: 5 5 | username: 6 | password: -------------------------------------------------------------------------------- /app/models/payola/coupon.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class Coupon < ActiveRecord::Base 3 | validates_uniqueness_of :code 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/payola/affiliate.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class Affiliate < ActiveRecord::Base 3 | validates_uniqueness_of :code 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/payola/stripe_webhook.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class StripeWebhook < ActiveRecord::Base 3 | validates_uniqueness_of :stripe_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | database: 2 | override: 3 | - cp config/database.yml.ci config/database.yml 4 | - bundle exec rake db:create db:migrate db:seed --trace 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/product.rb: -------------------------------------------------------------------------------- 1 | class Product < ActiveRecord::Base 2 | include Payola::Sellable 3 | 4 | def redirect_path(sale) 5 | '/' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/models/subscription_plan_without_interval_count.rb: -------------------------------------------------------------------------------- 1 | class SubscriptionPlanWithoutIntervalCount < ActiveRecord::Base 2 | include Payola::Plan 3 | end 4 | -------------------------------------------------------------------------------- /app/controllers/payola/application_controller.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ApplicationController < ::ApplicationController 3 | helper PriceHelper 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/services/payola/process_sale.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ProcessSale 3 | def self.call(guid) 4 | Sale.find_by(guid: guid).process! 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | 3 | protect_from_forgery :except => [:index] 4 | def index 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/factories/product.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :product do 3 | name "Foo" 4 | sequence(:permalink) { |n| "foo-#{n}" } 5 | price 100 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/buy.css: -------------------------------------------------------------------------------- 1 | /* 2 | Place all the styles related to the matching controller here. 3 | They will automatically be included in application.css. 4 | */ 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /app/views/payola/transactions/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'form', stripe_key: Payola.publishable_key, permalink: @product.permalink, sale: @sale, price: formatted_price(@price), coupon: @coupon %> 2 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/buy.js: -------------------------------------------------------------------------------- 1 | // Place all the behaviors and hooks related to the matching controller here. 2 | // All this logic will automatically be available in application.js. 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/buy_controller.rb: -------------------------------------------------------------------------------- 1 | class BuyController < ApplicationController 2 | helper Payola::PriceHelper 3 | 4 | def index 5 | @product = Product.first 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/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 | -------------------------------------------------------------------------------- /app/services/payola/process_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ProcessSubscription 3 | def self.call(guid) 4 | Subscription.find_by(guid: guid).process! 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/payola/transactions/show.html.erb: -------------------------------------------------------------------------------- 1 |

Thanks!

2 | 3 |

Thanks for buying "<%= @product.name %>". You'll receive an email shortly with your receipt and download link.

4 | 5 | 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/services/payola/send_mail.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class SendMail 3 | def self.call(mailer, method, *args) 4 | mailer.safe_constantize.send(method, *args).deliver 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141017233304_add_currency_to_payola_sale.rb: -------------------------------------------------------------------------------- 1 | class AddCurrencyToPayolaSale < ActiveRecord::Migration 2 | def change 3 | add_column :payola_sales, :currency, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/helpers/payola/price_helper.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module PriceHelper 3 | def formatted_price(amount, opts = {}) 4 | number_to_currency((amount || 0) / 100.0, opts) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20141029140518_create_owners.rb: -------------------------------------------------------------------------------- 1 | class CreateOwners < ActiveRecord::Migration 2 | def change 3 | create_table :owners do |t| 4 | 5 | t.timestamps 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20141204170622_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table :users do |t| 4 | 5 | t.timestamps 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20141026101628_add_custom_fields_to_payola_sale.rb: -------------------------------------------------------------------------------- 1 | class AddCustomFieldsToPayolaSale < ActiveRecord::Migration 2 | def change 3 | add_column :payola_sales, :custom_fields, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/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 | -------------------------------------------------------------------------------- /spec/factories/sales.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :sale, class: Payola::Sale do 3 | email 'test@example.com' 4 | product 5 | stripe_token 'tok_test' 6 | currency 'usd' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20141213205847_add_active_to_payola_coupon.rb: -------------------------------------------------------------------------------- 1 | class AddActiveToPayolaCoupon < ActiveRecord::Migration 2 | def change 3 | add_column :payola_coupons, :active, :boolean, default: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/payola/worker/base.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module Worker 3 | class BaseWorker 4 | def perform(klass, *args) 5 | klass.safe_constantize.call(*args) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/views/subscribe/show.html.erb: -------------------------------------------------------------------------------- 1 |

Subscription #<%= @subscription.guid %>

2 | <% if @subscription.active? %> 3 | <%= render 'payola/subscriptions/cancel', :subscription => @subscription %> 4 | <% end %> 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | spec/dummy/db/*.sqlite3 5 | spec/dummy/db/*.sqlite3-journal 6 | spec/dummy/log/*.log 7 | spec/dummy/tmp/ 8 | spec/dummy/.sass-cache 9 | Gemfile.lock 10 | coverage 11 | .rspec -------------------------------------------------------------------------------- /db/migrate/20141114032013_add_coupon_code_to_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddCouponCodeToPayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :coupon, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20141122020755_add_setup_fee_to_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddSetupFeeToPayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :setup_fee, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20141109203101_add_stripe_status_to_payola_subscription.rb: -------------------------------------------------------------------------------- 1 | class AddStripeStatusToPayolaSubscription < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :stripe_status, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20141112024805_add_affiliate_id_to_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddAffiliateIdToPayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :affiliate_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/payola/transactions/_stripe_header.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /db/migrate/20141114154223_add_signed_custom_fields_to_payola_subscription.rb: -------------------------------------------------------------------------------- 1 | class AddSignedCustomFieldsToPayolaSubscription < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :signed_custom_fields, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/payola_coupons.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :payola_coupon, :class => 'Payola::Coupon' do 5 | code "MyString" 6 | percent_off 1 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/payola/transactions/iframe.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'form', :username => @product.user.username, stripe_key: @product.user.stripe_publishable_key, :permalink => @product.permalink, :sale => @sale, :price => formatted_price(@price), :coupon => @coupon %> 2 | 3 | -------------------------------------------------------------------------------- /db/migrate/20141107025420_add_guid_to_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddGuidToPayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :guid, :string 4 | add_index :payola_subscriptions, :guid 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |

Hi there!

2 |

This is a fake home page. Maybe you'd like to:

3 | 7 | 8 | -------------------------------------------------------------------------------- /spec/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 | -------------------------------------------------------------------------------- /spec/dummy/spec/factories/products.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :product do 5 | name "MyString" 6 | permalink "MyString" 7 | price 1 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20141002203725_add_stripe_customer_id_to_sale.rb: -------------------------------------------------------------------------------- 1 | class AddStripeCustomerIdToSale < ActiveRecord::Migration 2 | def change 3 | add_column :payola_sales, :stripe_customer_id, :string 4 | add_index :payola_sales, :stripe_customer_id 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20141001203541_create_payola_stripe_webhooks.rb: -------------------------------------------------------------------------------- 1 | class CreatePayolaStripeWebhooks < ActiveRecord::Migration 2 | def change 3 | create_table :payola_stripe_webhooks do |t| 4 | t.string :stripe_id 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20141026144800_rename_custom_fields_to_signed_custom_fields_on_payola_sale.rb: -------------------------------------------------------------------------------- 1 | class RenameCustomFieldsToSignedCustomFieldsOnPayolaSale < ActiveRecord::Migration 2 | def change 3 | rename_column :payola_sales, :custom_fields, :signed_custom_fields 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 6 | -------------------------------------------------------------------------------- /db/migrate/20141106034610_add_currency_to_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class AddCurrencyToPayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :currency, :string 4 | add_column :payola_subscriptions, :amount, :integer 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/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 File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /db/migrate/20141002013618_create_payola_coupons.rb: -------------------------------------------------------------------------------- 1 | class CreatePayolaCoupons < ActiveRecord::Migration 2 | def change 3 | create_table :payola_coupons do |t| 4 | t.string :code 5 | t.integer :percent_off 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20141114163841_add_addresses_to_payola_subscription.rb: -------------------------------------------------------------------------------- 1 | class AddAddressesToPayolaSubscription < ActiveRecord::Migration 2 | def change 3 | add_column :payola_subscriptions, :customer_address, :text 4 | add_column :payola_subscriptions, :business_address, :text 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/payola.rb: -------------------------------------------------------------------------------- 1 | require 'sucker_punch' 2 | 3 | Payola.configure do |payola| 4 | 5 | payola.secret_key = 'sk_test_TYtgGt8qBaUpEJh0ZIY1jUuO' 6 | payola.publishable_key = 'pk_test_KeUPeR6mUmS67g2YdJ9nhqBF' 7 | 8 | payola.background_worker = :sucker_punch 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20141001230848_create_products.rb: -------------------------------------------------------------------------------- 1 | class CreateProducts < ActiveRecord::Migration 2 | def change 3 | create_table :products do |t| 4 | t.string :name 5 | t.string :permalink 6 | t.integer :price 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/factories/payola_affiliates.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :payola_affiliate, :class => 'Payola::Affiliate' do 5 | code "MyString" 6 | email "foo@example.com" 7 | percent 100 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/services/payola/sync_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class SyncSubscription 3 | def self.call(event) 4 | stripe_sub = event.data.object 5 | 6 | sub = Payola::Subscription.find_by!(stripe_id: stripe_sub.id) 7 | 8 | sub.sync_with!(stripe_sub) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20141029135848_add_owner_to_payola_sale.rb: -------------------------------------------------------------------------------- 1 | class AddOwnerToPayolaSale < ActiveRecord::Migration 2 | def change 3 | add_column :payola_sales, :owner_id, :integer 4 | add_column :payola_sales, :owner_type, :string 5 | 6 | add_index :payola_sales, [:owner_id, :owner_type] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/services/payola/update_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class UpdateSubscription 3 | def self.call(event) 4 | stripe_sub = event.data.object 5 | 6 | sub = Payola::Subscription.find_by(stripe_id: stripe_sub.id) 7 | 8 | sub.sync_with!(stripe_sub) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20141002013701_create_payola_affiliates.rb: -------------------------------------------------------------------------------- 1 | class CreatePayolaAffiliates < ActiveRecord::Migration 2 | def change 3 | create_table :payola_affiliates do |t| 4 | t.string :code 5 | t.string :email 6 | t.integer :percent 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/payola/admin_mailer/refund.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Refund!

5 |

To: <%= @sale.email %>

6 |

Guid: <%= @sale.guid %>

7 |

Product: <%= @product.name %>

8 |

Amount: <%= formatted_price(@sale.amount) %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /app/views/payola/admin_mailer/dispute.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Dispute! Boo!

5 |

To: <%= @sale.email %>

6 |

Guid: <%= @sale.guid %>

7 |

Product: <%= @product.name %>

8 |

Amount: <%= formatted_price(@sale.amount) %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /app/views/payola/admin_mailer/receipt.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Sale! Woohoo!

5 |

To: <%= @sale.email %>

6 |

Guid: <%= @sale.guid %>

7 |

Product: <%= @product.name %>

8 |

Amount: <%= formatted_price(@sale.amount) %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /app/views/payola/receipt_mailer/receipt.text.erb: -------------------------------------------------------------------------------- 1 | Thanks for buying <%= @product.name %>. 2 | 3 | Your transaction ID is: <%= @sale.guid %> 4 | 5 | Click here to pick up your product: <%= payola.confirm_url(guid: @sale.guid) %> 6 | 7 | <% if Payola.pdf_receipt %> 8 | Attached you'll find a PDF receipt for your records. 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/services/payola/invoice_paid.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class InvoicePaid 3 | include Payola::InvoiceBehavior 4 | 5 | def self.call(event) 6 | sale, charge = create_sale_from_event(event) 7 | 8 | return unless sale 9 | 10 | sale.save! 11 | sale.finish! 12 | 13 | sale 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/payola/receipt_mailer/refund.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | This email confirms that your purchase has been refunded. <%= formatted_price(@sale.amount) %> will be returned to your <%= @sale.card_type %> ending in <%= @sale.card_last4 %> within 5-10 business days. 6 |

7 | 8 | 9 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :buy 3 | get 'subscribe' => 'subscribe#index' 4 | post 'subscribe' => 'subscribe#create' 5 | get 'subscription/:guid' => 'subscribe#show' 6 | mount Payola::Engine => "/subdir/payola", as: :payola 7 | root 'home#index' 8 | post '' => 'home#index' 9 | end 10 | -------------------------------------------------------------------------------- /app/views/layouts/payola/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Payola 5 | <%= stylesheet_link_tag "payola/application", media: "all" %> 6 | <%= javascript_include_tag "payola/application" %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/views/payola/receipt_mailer/receipt.html.erb: -------------------------------------------------------------------------------- 1 |

Thank you for buying <%= @product.name %>

2 |

Your transaction ID is: <%= @sale.guid %>

3 |

<%= link_to "Click here to pick up your product", payola.confirm_url(guid: @sale.guid) %>

4 | <% if Payola.pdf_receipt %> 5 |

Attached you'll find a PDF receipt for your records.

6 | <% end %> 7 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | def payola_can_modify_subscription?(subscription) 7 | true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/payola/admin_mailer/failure.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Failure!

5 |

To: <%= @sale.email %>

6 |

Guid: <%= @sale.guid %>

7 |

Product: <%= @product.name %>

8 |

Amount: <%= formatted_price(@sale.amount) %>

9 |

Error: <%= @sale.error %>

10 | 11 | 12 | -------------------------------------------------------------------------------- /app/services/payola/invoice_failed.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class InvoiceFailed 3 | include Payola::InvoiceBehavior 4 | 5 | def self.call(event) 6 | sale, charge = create_sale_from_event(event) 7 | 8 | return unless sale 9 | 10 | sale.error = charge.failure_message 11 | sale.save! 12 | sale.fail! 13 | 14 | sale 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Okapi LLC 2 | 3 | Payola is an Open Source project licensed under the terms of 4 | the LGPLv3 license. Please see 5 | for license text. 6 | 7 | Payola Pro has a commercial-friendly license allowing private forks 8 | and modifications of Payola. See https://www.payola.io/pro for 9 | more details. You can find the commercial license terms in COMM-LICENSE. -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20141120170744_create_subscription_plan_without_interval_counts.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscriptionPlanWithoutIntervalCounts < ActiveRecord::Migration 2 | def change 3 | create_table :subscription_plan_without_interval_counts do |t| 4 | t.string :name 5 | t.string :stripe_id 6 | t.integer :amount 7 | t.string :interval 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Precompile additional assets. 7 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 8 | # Rails.application.config.assets.precompile += %w( search.js ) 9 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20141105010234_create_subscription_plans.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscriptionPlans < ActiveRecord::Migration 2 | def change 3 | create_table :subscription_plans do |t| 4 | t.integer :amount 5 | t.string :interval 6 | t.integer :interval_count 7 | t.string :name 8 | t.string :stripe_id 9 | t.integer :trial_period_days 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/services/payola/process_sale_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe ProcessSale do 5 | describe "#call" do 6 | it "should call process!" do 7 | sale = create(:sale) 8 | expect(Payola::Sale).to receive(:find_by).with(guid: sale.guid).and_return(sale) 9 | expect(sale).to receive(:process!) 10 | 11 | Payola::ProcessSale.call(sale.guid) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/payola/worker/sidekiq.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'sidekiq' 3 | rescue LoadError 4 | end 5 | 6 | module Payola 7 | module Worker 8 | class Sidekiq < BaseWorker 9 | include ::Sidekiq::Worker if defined? ::Sidekiq::Worker 10 | 11 | def self.can_run? 12 | defined?(::Sidekiq::Worker) 13 | end 14 | 15 | def self.call(klass, *args) 16 | perform_async(klass.to_s, *args) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/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 | -------------------------------------------------------------------------------- /spec/services/payola/sync_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe SyncSubscription do 5 | it "should call sync_with!" do 6 | expect_any_instance_of(Payola::Subscription).to receive(:sync_with!) 7 | sub = create(:subscription) 8 | 9 | event = StripeMock.mock_webhook_event('customer.subscription.updated', id: sub.stripe_id) 10 | 11 | Payola::SyncSubscription.call(event) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/payola/worker/sucker_punch.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'sucker_punch' 3 | rescue LoadError 4 | end 5 | 6 | module Payola 7 | module Worker 8 | class SuckerPunch < BaseWorker 9 | include ::SuckerPunch::Job if defined? ::SuckerPunch::Job 10 | 11 | def self.can_run? 12 | defined?(::SuckerPunch::Job) 13 | end 14 | 15 | def self.call(klass, *args) 16 | new.async.perform(klass.to_s, *args) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/services/payola/cancel_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class CancelSubscription 3 | def self.call(subscription) 4 | secret_key = Payola.secret_key_for_sale(subscription) 5 | Stripe.api_key = secret_key 6 | customer = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key) 7 | customer.subscriptions.retrieve(subscription.stripe_id,secret_key).delete({},secret_key) 8 | subscription.cancel! 9 | end 10 | 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /spec/services/payola/process_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe ProcessSubscription do 5 | describe "#call" do 6 | it "should call process!" do 7 | sale = create(:sale) 8 | expect(Payola::Subscription).to receive(:find_by).with(guid: sale.guid).and_return(sale) 9 | expect(sale).to receive(:process!) 10 | 11 | Payola::ProcessSubscription.call(sale.guid) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/payola/worker/active_job.rb: -------------------------------------------------------------------------------- 1 | module ::ActiveJob 2 | class Base; end 3 | end 4 | 5 | module Payola 6 | module Worker 7 | class ActiveJob < ::ActiveJob::Base 8 | def self.can_run? 9 | defined?(::ActiveJob::Core) 10 | end 11 | 12 | def self.call(klass, *args) 13 | perform_later(klass.to_s, *args) 14 | end 15 | 16 | def perform(klass, *args) 17 | klass.safe_constantize.call(*args) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/models/payola/stripe_webhook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe StripeWebhook do 5 | it "should validate" do 6 | s = StripeWebhook.new(stripe_id: 'test_id') 7 | expect(s.valid?).to be true 8 | end 9 | 10 | it "should validate stripe_id" do 11 | s = StripeWebhook.create(stripe_id: 'test_id') 12 | s2 = StripeWebhook.new(stripe_id: 'test_id') 13 | 14 | expect(s2.valid?).to be false 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/concerns/payola/affiliate_behavior.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module AffiliateBehavior 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | before_filter :find_affiliate 7 | end 8 | 9 | def find_affiliate 10 | affiliate_code = cookies[:aff] || params[:aff] 11 | @affiliate = Affiliate.where('lower(code) = lower(?)', affiliate_code).first 12 | if @affiliate 13 | cookies[:aff] = affiliate_code 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/concerns/payola/guid_behavior.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Payola 4 | module GuidBehavior 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | before_save :populate_guid 9 | validates_uniqueness_of :guid 10 | end 11 | 12 | def populate_guid 13 | if new_record? 14 | while !valid? || self.guid.nil? 15 | self.guid = Payola.guid_generator.call 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/payola/subscriptions/_change_plan.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | button_class = local_assigns.fetch :button_class, 'stripe-button-el' 3 | button_text = local_assigns.fetch :button_text, "Update Plan" 4 | %> 5 | 6 | <%= form_for subscription, method: :post, url: payola.change_subscription_plan_path(subscription) do |f| %> 7 | <%= hidden_field_tag 'plan_class', new_plan.plan_class %> 8 | <%= hidden_field_tag 'plan_id', new_plan.id %> 9 | <%= button_tag button_text, class: button_class %> 10 | <% end %> 11 | -------------------------------------------------------------------------------- /spec/factories/subscription_plan.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :subscription_plan do 3 | sequence(:name) { |n| "Foo #{n}" } 4 | sequence(:stripe_id) { |n| "foo-#{n}" } 5 | amount 100 6 | interval "month" 7 | interval_count 1 8 | end 9 | 10 | factory :subscription_plan_without_interval_count do 11 | sequence(:name) { |n| "Foo Without Interval #{n}" } 12 | sequence(:stripe_id) { |n| "foo-without-interval-#{n}" } 13 | amount 100 14 | interval "month" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application. 3 | 4 | ENGINE_ROOT = File.expand_path('../..', __FILE__) 5 | ENGINE_PATH = File.expand_path('../../lib/payola/engine', __FILE__) 6 | 7 | # Set up gems listed in the Gemfile. 8 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 9 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 10 | 11 | require 'rails/all' 12 | require 'rails/engine/commands' 13 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/subscribe_controller.rb: -------------------------------------------------------------------------------- 1 | class SubscribeController < ApplicationController 2 | helper Payola::PriceHelper 3 | include Payola::StatusBehavior 4 | 5 | def index 6 | @plan = SubscriptionPlan.first 7 | end 8 | 9 | def show 10 | @subscription = Payola::Subscription.find_by!(guid: params[:guid]) 11 | end 12 | 13 | def create 14 | params[:plan] = SubscriptionPlan.first 15 | subscription = Payola::CreateSubscription.call(params) 16 | render_payola_status(subscription) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/payola/subscriptions/_cancel.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | button_class = local_assigns.fetch :button_class, 'stripe-button-el' 3 | button_text = local_assigns.fetch :button_text, "Cancel Subscription" 4 | confirm_text = local_assigns.fetch :confirm_text, "Are you sure?" 5 | disabled = subscription.active? 6 | %> 7 | 8 | <%= form_tag payola.cancel_subscription_path(subscription.guid), :method => :delete do %> 9 | <%= submit_tag button_text, html: { disabled: disabled, class: button_class }, data: { confirm: confirm_text } %> 10 | <% end %> 11 | -------------------------------------------------------------------------------- /app/controllers/concerns/payola/status_behavior.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module StatusBehavior 3 | extend ActiveSupport::Concern 4 | 5 | def render_payola_status(object) 6 | render nothing: true, status: 404 and return unless object 7 | 8 | errors = ([object.error.presence] + object.errors.full_messages).compact.to_sentence 9 | 10 | render json: { 11 | guid: object.guid, 12 | status: object.state, 13 | error: errors.presence 14 | }, status: errors.blank? ? 200 : 400 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 6 | 7 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 8 | <%= csrf_meta_tags %> 9 | <%= render 'payola/transactions/stripe_header' %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /spec/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] if respond_to?(:wrap_parameters) 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 | -------------------------------------------------------------------------------- /spec/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /lib/generators/payola/install_generator.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class InstallGenerator < Rails::Generators::Base 3 | source_root File.expand_path('../templates', __FILE__) 4 | 5 | def install_initializer 6 | initializer 'payola.rb', File.read(File.expand_path('../templates/initializer.rb', __FILE__)) 7 | end 8 | 9 | def install_js 10 | inject_into_file 'app/assets/javascripts/application.js', after: "//= require jquery\n" do <<-'JS' 11 | //= require payola 12 | JS 13 | end 14 | end 15 | 16 | def install_route 17 | route "mount Payola::Engine => '/payola', as: :payola" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola/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 vendor/assets/javascripts of plugins, if any, 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. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /app/models/concerns/payola/sellable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Payola 4 | module Sellable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | validates_presence_of :name 9 | validates_presence_of :permalink 10 | validates_presence_of :price 11 | validates_uniqueness_of :permalink 12 | 13 | Payola.register_sellable(self) 14 | end 15 | 16 | def product_class 17 | self.class.product_class 18 | end 19 | 20 | module ClassMethods 21 | def sellable? 22 | true 23 | end 24 | 25 | def product_class 26 | self.to_s.underscore 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Declare your gem's dependencies in payola.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | 13 | # To use debugger 14 | # gem 'debugger' 15 | gem 'simplecov', :require => false, :group => :test 16 | gem "codeclimate-test-reporter", group: :test, require: nil 17 | 18 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, 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. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | //= require payola 15 | -------------------------------------------------------------------------------- /app/services/payola/create_plan.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class CreatePlan 3 | def self.call(plan) 4 | secret_key = Payola.secret_key_for_sale(plan) 5 | 6 | Stripe::Plan.create({ 7 | id: plan.stripe_id, 8 | amount: plan.amount, 9 | interval: plan.interval, 10 | name: plan.name, 11 | interval_count: plan.respond_to?(:interval_count) ? plan.interval_count : nil, 12 | currency: plan.respond_to?(:currency) ? plan.currency : Payola.default_currency, 13 | trial_period_days: plan.respond_to?(:trial_period_days) ? plan.trial_period_days : nil 14 | }, secret_key) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/services/payola/send_mail_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | 5 | class TestMailer < ActionMailer::Base 6 | def test_mail(to, text) 7 | mail( 8 | to: to, 9 | from: 'from@example.com', 10 | body: text 11 | ) 12 | end 13 | end 14 | 15 | describe SendMail do 16 | describe "#call" do 17 | it "should send a mail" do 18 | mail = double 19 | expect(TestMailer).to receive(:test_mail).with('to@example.com', 'Some Text').and_return(mail) 20 | expect(mail).to receive(:deliver) 21 | Payola::SendMail.call('Payola::TestMailer', 'test_mail', 'to@example.com', 'Some Text') 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/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 | -------------------------------------------------------------------------------- /spec/helpers/payola/price_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Payola::PriceHelper do 5 | 6 | describe "#formatted_price" do 7 | it { expect(helper.formatted_price(nil)).to eq "$0.00" } 8 | it { expect(helper.formatted_price(0)).to eq "$0.00" } 9 | it { expect(helper.formatted_price(2)).to eq "$0.02" } 10 | it { expect(helper.formatted_price(20)).to eq "$0.20" } 11 | it { expect(helper.formatted_price(2000)).to eq "$20.00" } 12 | it { expect(helper.formatted_price(200.0)).to eq "$2.00" } 13 | 14 | it { expect(helper.formatted_price(200, unit: '£', separator: ',')).to eq "£2,00" } 15 | it { expect(helper.formatted_price(200, locale: :de)).to eq "2,00 €" } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /app/assets/stylesheets/payola/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 vendor/assets/stylesheets of plugins, if any, 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 styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/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 vendor/assets/stylesheets of plugins, if any, 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 styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/models/payola/coupon_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe Coupon do 5 | describe "validations" do 6 | it "should validate uniqueness of coupon code" do 7 | c1 = Coupon.create(code: 'abc') 8 | expect(c1.valid?).to be_truthy 9 | 10 | c2 = Coupon.new(code: 'abc') 11 | expect(c2.valid?).to be_falsey 12 | end 13 | end 14 | 15 | describe "active" do 16 | it "should allow active flag" do 17 | c1 = Coupon.create(code: 'abc', active: false) 18 | expect(c1.active?).to be_falsey 19 | end 20 | 21 | it "should be true by default" do 22 | c1 = Coupon.create(code: 'abc') 23 | expect(c1.active?).to be_truthy 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/mailers/payola/admin_mailer.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class AdminMailer < ActionMailer::Base 3 | helper Payola::PriceHelper 4 | 5 | def receipt(sale_guid) 6 | send_admin_mail(sale_guid) 7 | end 8 | 9 | def refund(sale_guid) 10 | send_admin_mail(sale_guid) 11 | end 12 | 13 | def dispute(sale_guid) 14 | send_admin_mail(sale_guid) 15 | end 16 | 17 | def failure(sale_guid) 18 | send_admin_mail(sale_guid) 19 | end 20 | 21 | def send_admin_mail(sale_guid) 22 | ActiveRecord::Base.connection_pool.with_connection do 23 | @sale = Payola::Sale.find_by(guid: sale_guid) 24 | @product = @sale.product 25 | mail(from: Payola.support_email, to: Payola.support_email) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/services/payola/change_subscription_quantity.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ChangeSubscriptionQuantity 3 | def self.call(subscription, quantity) 4 | secret_key = Payola.secret_key_for_sale(subscription) 5 | old_quantity = subscription.quantity 6 | 7 | begin 8 | customer = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key) 9 | sub = customer.subscriptions.retrieve(subscription.stripe_id) 10 | sub.quantity = quantity 11 | sub.save 12 | 13 | subscription.quantity = quantity 14 | subscription.save! 15 | 16 | subscription.instrument_plan_changed(old_quantity) 17 | 18 | rescue RuntimeError, Stripe::StripeError => e 19 | subscription.errors[:base] << e.message 20 | end 21 | 22 | subscription 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/services/payola/create_sale.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class CreateSale 3 | def self.call(params) 4 | product = params[:product] 5 | affiliate = params[:affiliate] 6 | coupon = params[:coupon] 7 | 8 | Payola::Sale.new do |s| 9 | s.product = product 10 | s.email = params[:stripeEmail] 11 | s.stripe_token = params[:stripeToken] 12 | s.affiliate_id = affiliate.try(:id) 13 | s.currency = product.respond_to?(:currency) ? product.currency : Payola.default_currency 14 | s.signed_custom_fields = params[:signed_custom_fields] 15 | 16 | if coupon 17 | s.coupon = coupon 18 | s.amount = product.price * (1 - s.coupon.percent_off / 100.0) 19 | else 20 | s.amount = product.price 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/factories/payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :subscription, :class => 'Payola::Subscription' do 5 | plan_type "SubscriptionPlan" 6 | plan_id 1 7 | start "2014-11-04 22:34:39" 8 | status "MyString" 9 | owner_type "Owner" 10 | owner_id 1 11 | stripe_customer_id "MyString" 12 | cancel_at_period_end false 13 | current_period_start "2014-11-04 22:34:39" 14 | current_period_end "2014-11-04 22:34:39" 15 | ended_at "2014-11-04 22:34:39" 16 | trial_start "2014-11-04 22:34:39" 17 | trial_end "2014-11-04 22:34:39" 18 | canceled_at "2014-11-04 22:34:39" 19 | email "jeremy@octolabs.com" 20 | stripe_token "yyz123" 21 | currency 'usd' 22 | quantity 1 23 | stripe_id 'sub_123456' 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/payola/transactions/wait.html.erb: -------------------------------------------------------------------------------- 1 | Please wait... 2 | 3 | 4 | 5 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /spec/dummy/app/views/buy/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'payola/transactions/checkout', sellable: @product, button_class: 'not-a-class' %> 2 | 3 |
4 | 5 | <%= form_for @product, url: '/', method: :post, html: { class: 'payola-payment-form', 'data-payola-base-path' => '/subdir/payola', 'data-payola-product' => @product.product_class, 'data-payola-permalink' => @product.permalink } do |f| %> 6 | 7 | Email:
8 |
9 | Card Number
10 |
11 | Exp Month
12 |
13 | Exp Year
14 |
15 | CVC
16 |
17 | 18 | <% end %> 19 | -------------------------------------------------------------------------------- /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 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'Payola' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.rdoc') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__) 18 | load 'rails/tasks/engine.rake' 19 | 20 | Bundler::GemHelper.install_tasks 21 | 22 | Dir[File.join(File.dirname(__FILE__), 'tasks/**/*.rake')].each {|f| load f } 23 | 24 | require 'rspec/core' 25 | require 'rspec/core/rake_task' 26 | 27 | desc "Run all specs in spec directory (excluding plugin specs)" 28 | RSpec::Core::RakeTask.new(:spec => 'app:db:test:prepare') 29 | 30 | task :default => :spec 31 | -------------------------------------------------------------------------------- /app/services/payola/create_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class CreateSubscription 3 | def self.call(params, owner=nil) 4 | plan = params[:plan] 5 | affiliate = params[:affiliate] 6 | 7 | sub = Payola::Subscription.new do |s| 8 | s.plan = plan 9 | s.email = params[:stripeEmail] 10 | s.stripe_token = params[:stripeToken] 11 | s.affiliate_id = affiliate.try(:id) 12 | s.currency = plan.respond_to?(:currency) ? plan.currency : Payola.default_currency 13 | s.coupon = params[:coupon] 14 | s.signed_custom_fields = params[:signed_custom_fields] 15 | s.setup_fee = params[:setup_fee] 16 | s.quantity = params[:quantity] 17 | 18 | s.owner = owner 19 | s.amount = plan.amount 20 | end 21 | 22 | if sub.save 23 | Payola.queue!(Payola::ProcessSubscription, sub.guid) 24 | end 25 | 26 | sub 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/services/payola/change_subscription_plan.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ChangeSubscriptionPlan 3 | def self.call(subscription, plan) 4 | secret_key = Payola.secret_key_for_sale(subscription) 5 | old_plan = subscription.plan 6 | 7 | begin 8 | customer = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key) 9 | sub = customer.subscriptions.retrieve(subscription.stripe_id) 10 | 11 | prorate = plan.respond_to?(:should_prorate?) ? plan.should_prorate?(subscription) : true 12 | 13 | sub.plan = plan.stripe_id 14 | sub.prorate = prorate 15 | sub.save 16 | 17 | subscription.plan = plan 18 | subscription.save! 19 | 20 | subscription.instrument_plan_changed(old_plan) 21 | 22 | rescue RuntimeError, Stripe::StripeError => e 23 | subscription.errors[:base] << e.message 24 | end 25 | 26 | subscription 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/services/payola/update_card.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class UpdateCard 3 | def self.call(subscription, token) 4 | secret_key = Payola.secret_key_for_sale(subscription) 5 | begin 6 | customer = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key) 7 | 8 | customer.card = token 9 | customer.save 10 | 11 | customer = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key) 12 | card = customer.cards.retrieve(customer.default_card, secret_key) 13 | 14 | subscription.update_attributes( 15 | card_type: card.brand, 16 | card_last4: card.last4, 17 | card_expiration: Date.parse("#{card.exp_year}/#{card.exp_month}/1") 18 | ) 19 | subscription.save! 20 | rescue RuntimeError, Stripe::StripeError => e 21 | subscription.errors[:base] << e.message 22 | end 23 | 24 | subscription 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/concerns/sellable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe Sellable do 5 | describe "validations" do 6 | it "should validate" do 7 | product = build(:product) 8 | expect(product.valid?).to be true 9 | end 10 | it "should validate name" do 11 | product = build(:product, name: nil) 12 | expect(product.valid?).to be false 13 | end 14 | it "should validate permalink" do 15 | product = build(:product, permalink: nil) 16 | expect(product.valid?).to be false 17 | end 18 | end 19 | 20 | describe "#product_class" do 21 | it "should return the underscore'd version of the class" do 22 | expect(build(:product).product_class).to eq 'product' 23 | end 24 | end 25 | 26 | describe "#sellable?" do 27 | it "should return true" do 28 | expect(Product.sellable?).to be true 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/services/payola/update_card_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe UpdateCard do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | 7 | describe "#call" do 8 | before do 9 | @plan = create(:subscription_plan) 10 | 11 | token = StripeMock.generate_card_token({}) 12 | @subscription = create(:subscription, plan: @plan, stripe_token: token) 13 | StartSubscription.call(@subscription) 14 | token2 = StripeMock.generate_card_token({last4: '2233', exp_year: '2021', exp_month: '11', brand: 'JCB'}) 15 | Payola::UpdateCard.call(@subscription, token2) 16 | end 17 | 18 | it "should change the card" do 19 | @subscription.reload 20 | expect(@subscription.card_last4).to eq '2233' 21 | expect(@subscription.card_expiration).to eq Date.new(2021,11,1) 22 | expect(@subscription.card_type).to eq 'JCB' 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db/migrate/20141105043439_create_payola_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class CreatePayolaSubscriptions < ActiveRecord::Migration 2 | def change 3 | create_table :payola_subscriptions do |t| 4 | t.string :plan_type 5 | t.integer :plan_id 6 | t.timestamp :start 7 | t.string :status 8 | t.string :owner_type 9 | t.integer :owner_id 10 | t.string :stripe_customer_id 11 | t.boolean :cancel_at_period_end 12 | t.timestamp :current_period_start 13 | t.timestamp :current_period_end 14 | t.timestamp :ended_at 15 | t.timestamp :trial_start 16 | t.timestamp :trial_end 17 | t.timestamp :canceled_at 18 | t.integer :quantity 19 | t.string :stripe_id 20 | t.string :stripe_token 21 | t.string :card_last4 22 | t.date :card_expiration 23 | t.string :card_type 24 | t.text :error 25 | t.string :state 26 | t.string :email 27 | 28 | t.timestamps 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/generators/payola/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | Payola.configure do |config| 2 | # Example subscription: 3 | # 4 | # config.subscribe 'payola.package.sale.finished' do |sale| 5 | # EmailSender.send_an_email(sale.email) 6 | # end 7 | # 8 | # In addition to any event that Stripe sends, you can subscribe 9 | # to the following special payola events: 10 | # 11 | # - payola..sale.finished 12 | # - payola..sale.refunded 13 | # - payola..sale.failed 14 | # 15 | # These events consume a Payola::Sale, not a Stripe::Event 16 | # 17 | # Example charge verifier: 18 | # 19 | # config.charge_verifier = lambda do |sale| 20 | # raise "Nope!" if sale.email.includes?('yahoo.com') 21 | # end 22 | 23 | # Keep this subscription unless you want to disable refund handling 24 | config.subscribe 'charge.refunded' do |event| 25 | sale = Payola::Sale.find_by(stripe_id: event.data.object.id) 26 | sale.refund! 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/mailers/payola/admin_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe AdminMailer do 5 | let(:sale) { create(:sale) } 6 | 7 | describe '#receipt' do 8 | it "should send a receipt notification" do 9 | mail = AdminMailer.receipt(sale.guid) 10 | expect(mail.subject).to eq 'Receipt' 11 | end 12 | end 13 | 14 | describe '#refund' do 15 | it "should send a refund notification" do 16 | mail = AdminMailer.refund(sale.guid) 17 | expect(mail.subject).to eq 'Refund' 18 | end 19 | end 20 | 21 | describe '#dispute' do 22 | it "should send a dispute notification" do 23 | mail = AdminMailer.dispute(sale.guid) 24 | expect(mail.subject).to eq 'Dispute' 25 | end 26 | end 27 | 28 | describe '#failure' do 29 | it "should send a failure notification" do 30 | mail = AdminMailer.failure(sale.guid) 31 | expect(mail.subject).to eq 'Failure' 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | Bundler.require(*Rails.groups) 6 | require "payola" 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 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 15 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 16 | # config.time_zone = 'Central Time (US & Canada)' 17 | 18 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 19 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 20 | # config.i18n.default_locale = :de 21 | config.i18n.available_locales = [:en, :de] 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /spec/services/payola/invoice_failed_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe InvoiceFailed do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | it "should create a failed sale" do 7 | plan = create(:subscription_plan) 8 | 9 | customer = Stripe::Customer.create( 10 | email: 'foo', 11 | card: stripe_helper.generate_card_token, 12 | plan: plan.stripe_id 13 | ) 14 | 15 | sub = create(:subscription, plan: plan, stripe_customer_id: customer.id, stripe_id: customer.subscriptions.first.id) 16 | 17 | charge = Stripe::Charge.create(failure_message: 'Failed! OMG!') 18 | event = StripeMock.mock_webhook_event('invoice.payment_failed', subscription: sub.stripe_id, charge: charge.id) 19 | 20 | count = Payola::Sale.count 21 | sale = Payola::InvoiceFailed.call(event) 22 | 23 | expect(Payola::Sale.count).to eq count + 1 24 | 25 | expect(sale.errored?).to be true 26 | expect(sale.error).to eq 'Failed! OMG!' 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/services/payola/change_subscription_quantity_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe ChangeSubscriptionQuantity do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | 7 | describe "#call" do 8 | before do 9 | @plan = create(:subscription_plan) 10 | 11 | token = StripeMock.generate_card_token({}) 12 | @subscription = create(:subscription, quantity: 1, stripe_token: token) 13 | StartSubscription.call(@subscription) 14 | Payola::ChangeSubscriptionQuantity.call(@subscription, 2) 15 | end 16 | 17 | it "should change the quantity on the stripe subscription" do 18 | customer = Stripe::Customer.retrieve(@subscription.stripe_customer_id) 19 | sub = customer.subscriptions.retrieve(@subscription.stripe_id) 20 | 21 | expect(sub.quantity).to eq 2 22 | end 23 | 24 | it "should change the quantity on the payola subscription" do 25 | expect(@subscription.reload.quantity).to eq 2 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/services/payola/cancel_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe CancelSubscription do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | let(:token){ StripeMock.generate_card_token({}) } 7 | describe "#call" do 8 | before :each do 9 | plan = create(:subscription_plan) 10 | @subscription = create(:subscription, plan: plan, stripe_token: token) 11 | @subscription.process! 12 | end 13 | it "should cancel a subscription" do 14 | CancelSubscription.call(@subscription) 15 | expect(@subscription.reload.state).to eq 'canceled' 16 | end 17 | it "should not change the state if an error occurs" do 18 | custom_error = StandardError.new("Customer not found") 19 | StripeMock.prepare_error(custom_error, :get_customer) 20 | expect { CancelSubscription.call(@subscription) }.to raise_error 21 | 22 | expect(@subscription.reload.state).to eq 'active' 23 | end 24 | end 25 | end 26 | end 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/models/concerns/payola/plan.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Payola 4 | module Plan 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | validates_presence_of :amount 9 | validates_presence_of :interval 10 | validates_presence_of :stripe_id 11 | validates_presence_of :name 12 | 13 | validates_uniqueness_of :stripe_id 14 | 15 | before_create :create_stripe_plan 16 | 17 | has_many :subscriptions, :class_name => "Payola::Subscription" 18 | 19 | Payola.register_subscribable(self) 20 | end 21 | 22 | def create_stripe_plan 23 | Payola::CreatePlan.call(self) 24 | end 25 | 26 | def plan_class 27 | self.class.plan_class 28 | end 29 | 30 | def product_class 31 | plan_class 32 | end 33 | 34 | def price 35 | amount 36 | end 37 | 38 | module ClassMethods 39 | def subscribable? 40 | true 41 | end 42 | 43 | def plan_class 44 | self.to_s.underscore 45 | end 46 | end 47 | 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/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 `rake 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: f1f2572d010168e61cc6b901cadc66cc1ba2df5fb11f1aa16e60c96cff6dbbe2248ceacf81ea96e9015ca0ab2d219e616c3471260c97e302af75b12fcaf6ccd3 15 | 16 | test: 17 | secret_key_base: 5f23907a09acc8c65c6a4f05da1fdbc394320545717323b910d308e685b814f2789c9df0227492bbca8420221939ad04fedb6ec1a8c15af8e6e475bdf5f19ea5 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 | -------------------------------------------------------------------------------- /spec/dummy/app/views/subscribe/index.html.erb: -------------------------------------------------------------------------------- 1 | <%#= render 'payola/transactions/checkout', sellable: @product, button_class: 'not-a-class' %> 2 | 3 |
4 | 5 | <%= form_for @plan, url: '/', method: :post, html: { class: 'payola-subscription-form', 'data-payola-base-path' => '/subdir/payola', 'data-payola-plan-type' => @plan.plan_class, 'data-payola-plan-id' => @plan.id } do |f| %> 6 | 7 | Email:
8 |
9 | Card Number
10 |
11 | Exp Month
12 |
13 | Exp Year
14 |
15 | CVC
16 |
17 | Coupon Code
18 |
19 | Quantity
20 |
21 | 22 | <% end %> 23 | 24 | -------------------------------------------------------------------------------- /spec/services/payola/change_subscription_plan_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe ChangeSubscriptionPlan do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | 7 | describe "#call" do 8 | before do 9 | @plan1 = create(:subscription_plan) 10 | @plan2 = create(:subscription_plan) 11 | 12 | token = StripeMock.generate_card_token({}) 13 | @subscription = create(:subscription, plan: @plan1, stripe_token: token) 14 | StartSubscription.call(@subscription) 15 | Payola::ChangeSubscriptionPlan.call(@subscription, @plan2) 16 | end 17 | 18 | it "should change the plan on the stripe subscription" do 19 | customer = Stripe::Customer.retrieve(@subscription.stripe_customer_id) 20 | sub = customer.subscriptions.retrieve(@subscription.stripe_id) 21 | 22 | expect(sub.plan.id).to eq @plan2.stripe_id 23 | end 24 | 25 | it "should change the plan on the payola subscription" do 26 | expect(@subscription.reload.plan).to eq @plan2 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/payola/worker.rb: -------------------------------------------------------------------------------- 1 | require 'payola/worker/base' 2 | require 'payola/worker/active_job' 3 | require 'payola/worker/sidekiq' 4 | require 'payola/worker/sucker_punch' 5 | 6 | module Payola 7 | module Worker 8 | class << self 9 | attr_accessor :registry 10 | 11 | def find(symbol) 12 | if registry.has_key? symbol 13 | return registry[symbol] 14 | else 15 | raise "No such worker type: #{symbol}" 16 | end 17 | end 18 | 19 | def autofind 20 | # prefer ActiveJob over the other workers 21 | if Payola::Worker::ActiveJob.can_run? 22 | return Payola::Worker::ActiveJob 23 | end 24 | 25 | registry.values.each do |worker| 26 | if worker.can_run? 27 | return worker 28 | end 29 | end 30 | 31 | raise "No eligible background worker systems found." 32 | end 33 | end 34 | 35 | self.registry = { 36 | sidekiq: Payola::Worker::Sidekiq, 37 | sucker_punch: Payola::Worker::SuckerPunch, 38 | active_job: Payola::Worker::ActiveJob 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Payola::Engine.routes.draw do 2 | match '/buy/:product_class/:permalink' => 'transactions#create', via: :post, as: :buy 3 | match '/confirm/:guid' => 'transactions#show', via: :get, as: :confirm 4 | match '/status/:guid' => 'transactions#status', via: :get, as: :status 5 | 6 | match '/subscribe/:plan_class/:plan_id' => 'subscriptions#create', via: :post, as: :subscribe 7 | match '/confirm_subscription/:guid' => 'subscriptions#show', via: :get, as: :confirm_subscription 8 | match '/subscription_status/:guid' => 'subscriptions#status', via: :get, as: :subscription_status 9 | match '/cancel_subscription/:guid' => 'subscriptions#destroy', via: :delete, as: :cancel_subscription 10 | match '/change_plan/:guid' => 'subscriptions#change_plan', via: :post, as: :change_subscription_plan 11 | match '/change_quantity/:guid' => 'subscriptions#change_quantity', via: :post, as: :change_subscription_quantity 12 | match '/update_card/:guid' => 'subscriptions#update_card', via: :post, as: :update_card 13 | 14 | mount StripeEvent::Engine => '/events' 15 | end 16 | -------------------------------------------------------------------------------- /spec/services/payola/create_plan_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe CreatePlan do 5 | before do 6 | @subscription_plan = create(:subscription_plan) 7 | end 8 | 9 | describe "#call" do 10 | it "should create a plan at Stripe" do 11 | plan = Stripe::Plan.retrieve(@subscription_plan.stripe_id) 12 | 13 | expect(plan.name).to eq @subscription_plan.name 14 | expect(plan.amount).to eq @subscription_plan.amount 15 | expect(plan.id).to eq @subscription_plan.stripe_id 16 | expect(plan.interval).to eq @subscription_plan.interval 17 | expect(plan.interval_count).to eq @subscription_plan.interval_count 18 | expect(plan.currency).to eq 'usd' 19 | expect(plan.trial_period_days).to eq @subscription_plan.trial_period_days 20 | end 21 | 22 | it "should default interval_count" do 23 | our_plan = create(:subscription_plan_without_interval_count) 24 | 25 | expect(our_plan.respond_to?(:interval_count)).to eq false 26 | 27 | plan = Stripe::Plan.retrieve(our_plan.stripe_id) 28 | expect(plan.interval_count).to be_nil 29 | end 30 | end 31 | 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /db/migrate/20141001170138_create_payola_sales.rb: -------------------------------------------------------------------------------- 1 | class CreatePayolaSales < ActiveRecord::Migration 2 | def change 3 | create_table :payola_sales do |t| 4 | t.string "email" 5 | t.string "guid" 6 | t.integer "product_id" 7 | t.string "product_type" 8 | t.datetime "created_at" 9 | t.datetime "updated_at" 10 | t.string "state" 11 | t.string "stripe_id" 12 | t.string "stripe_token" 13 | t.string "card_last4" 14 | t.date "card_expiration" 15 | t.string "card_type" 16 | t.text "error" 17 | t.integer "amount" 18 | t.integer "fee_amount" 19 | t.integer "coupon_id" 20 | t.boolean "opt_in" 21 | t.integer "download_count" 22 | t.integer "affiliate_id" 23 | t.text "customer_address" 24 | t.text "business_address" 25 | t.timestamps 26 | end 27 | 28 | add_index "payola_sales", ["coupon_id"], name: "index_payola_sales_on_coupon_id", using: :btree 29 | add_index "payola_sales", ["product_id", "product_type"], name: "index_payola_sales_on_product", using: :btree 30 | add_index "payola_sales", ["email"], name: "index_payola_sales_on_email", using: :btree 31 | add_index "payola_sales", ["guid"], name: "index_payola_sales_on_guid", using: :btree 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/services/payola/create_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe CreateSubscription do 5 | before do 6 | @plan = create(:subscription_plan) 7 | Payola.background_worker = Payola::FakeWorker 8 | end 9 | 10 | describe "#call" do 11 | it "should create a subscription and queue the job" do 12 | expect(Payola).to receive(:queue!) 13 | 14 | sale = CreateSubscription.call( 15 | stripeEmail: 'pete@bugsplat.info', 16 | stripeToken: 'test_tok', 17 | plan: @plan 18 | ) 19 | 20 | expect(sale.email).to eq 'pete@bugsplat.info' 21 | expect(sale.stripe_token).to eq 'test_tok' 22 | expect(sale.plan_id).to eq @plan.id 23 | expect(sale.plan).to eq @plan 24 | expect(sale.plan_type).to eq 'SubscriptionPlan' 25 | expect(sale.currency).to eq 'usd' 26 | end 27 | 28 | it "should include the affiliate if given" do 29 | affiliate = create(:payola_affiliate) 30 | sale = CreateSubscription.call( 31 | email: 'pete@bugsplat.info', 32 | stripeToken: 'test_tok', 33 | plan: @plan, 34 | affiliate: affiliate 35 | ) 36 | 37 | expect(sale.affiliate).to eq affiliate 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/payola/engine.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Payola 4 | engine_name 'payola' 5 | 6 | config.generators do |g| 7 | g.test_framework :rspec, fixture: false 8 | g.fixture_replacement :factory_girl, dir: 'spec/factories' 9 | g.assets false 10 | g.helper false 11 | end 12 | 13 | initializer :append_migrations do |app| 14 | unless app.root.to_s.match root.to_s 15 | config.paths["db/migrate"].expanded.each do |expanded_path| 16 | app.config.paths["db/migrate"] << expanded_path 17 | end 18 | end 19 | end 20 | 21 | initializer :inject_helpers do |app| 22 | ActiveSupport.on_load :action_controller do 23 | ::ActionController::Base.send(:helper, Payola::PriceHelper) 24 | end 25 | 26 | ActiveSupport.on_load :action_mailer do 27 | ::ActionMailer::Base.send(:helper, Payola::PriceHelper) 28 | end 29 | end 30 | 31 | initializer :configure_subscription_listeners do |app| 32 | Payola.configure do |config| 33 | config.subscribe 'invoice.payment_succeeded', Payola::InvoicePaid 34 | config.subscribe 'invoice.payment_failed', Payola::InvoiceFailed 35 | config.subscribe 'customer.subscription.updated', Payola::SyncSubscription 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/controllers/concerns/payola/async_behavior.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | module AsyncBehavior 3 | extend ActiveSupport::Concern 4 | 5 | def show_object(object_class) 6 | object = object_class.find_by!(guid: params[:guid]) 7 | redirector = object.redirector 8 | 9 | new_path = redirector.respond_to?(:redirect_path) ? redirector.redirect_path(object) : '/' 10 | redirect_to new_path 11 | end 12 | 13 | def object_status(object_class) 14 | object = object_class.find_by(guid: params[:guid]) 15 | render_payola_status(object) 16 | end 17 | 18 | def create_object(object_class, object_creator_class, object_processor_class, product_key, product) 19 | create_params = if object_class == Subscription 20 | params.permit!.merge( 21 | product_key => product, 22 | coupon: @coupon, 23 | quantity: @quantity, 24 | affiliate: @affiliate 25 | ) 26 | else 27 | params.permit!.merge( 28 | product_key => product, 29 | coupon: @coupon, 30 | affiliate: @affiliate 31 | ) 32 | end 33 | 34 | object = object_creator_class.call(create_params) 35 | 36 | if object.save && object_processor_class.present? 37 | Payola.queue!(object_processor_class, object.guid) 38 | end 39 | 40 | render_payola_status(object) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/controllers/payola/transactions_controller.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class TransactionsController < ApplicationController 3 | include Payola::AffiliateBehavior 4 | include Payola::StatusBehavior 5 | include Payola::AsyncBehavior 6 | 7 | before_filter :find_product_and_coupon, only: [:create] 8 | 9 | def show 10 | show_object(Sale) 11 | end 12 | 13 | def status 14 | object_status(Sale) 15 | end 16 | 17 | def create 18 | create_object(Sale, CreateSale, ProcessSale, :product, @product) 19 | end 20 | 21 | private 22 | def find_product_and_coupon 23 | find_product 24 | find_coupon 25 | end 26 | 27 | def find_product 28 | @product_class = Payola.sellables[params[:product_class]] 29 | 30 | raise ActionController::RoutingError.new('Not Found') unless @product_class && @product_class.sellable? 31 | 32 | @product = @product_class.find_by!(permalink: params[:permalink]) 33 | end 34 | 35 | def find_coupon 36 | coupon_code = cookies[:cc] || params[:cc] || params[:coupon_code] 37 | @coupon = Coupon.where('lower(code) = lower(?)', coupon_code).first 38 | if @coupon && @coupon.active? 39 | cookies[:cc] = coupon_code 40 | @price = @product.price * (1 - @coupon.percent_off / 100.0) 41 | else 42 | @price = @product.price 43 | end 44 | end 45 | 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /payola.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "payola/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "payola-payments" 9 | s.version = Payola::VERSION 10 | s.authors = ["Pete Keen"] 11 | s.email = ["pete@payola.io"] 12 | s.homepage = "https://www.payola.io" 13 | s.summary = "Drop-in Rails engine for accepting payments with Stripe" 14 | s.description = "One-off and subscription payments for your Rails application" 15 | s.license = "LGPL-3.0" 16 | 17 | s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"] 18 | s.test_files = Dir["spec/**/*"] 19 | 20 | s.add_dependency "rails", ">= 4.1" 21 | s.add_dependency "jquery-rails" 22 | s.add_dependency "stripe", ">= 1.16.0" 23 | s.add_dependency "aasm", ">= 4.0.7" 24 | s.add_dependency "stripe_event", ">= 1.3.0" 25 | 26 | s.add_development_dependency "sqlite3" 27 | s.add_development_dependency "rspec-rails" 28 | s.add_development_dependency 'factory_girl_rails' 29 | s.add_development_dependency "stripe-ruby-mock", "~> 2.0" 30 | s.add_development_dependency "sucker_punch", "~> 1.2.1" 31 | s.add_development_dependency "docverter" 32 | 33 | s.post_install_message = <<-HERE 34 | Please ensure that your Payola route has 'as: :payola' included. Prior to v1.0.7 this was not added automatically. 35 | HERE 36 | end 37 | -------------------------------------------------------------------------------- /spec/concerns/plan_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe Plan do 5 | 6 | it "should validate" do 7 | subscription_plan = build(:subscription_plan) 8 | expect(subscription_plan.valid?).to be true 9 | end 10 | 11 | it "should validate amount" do 12 | subscription_plan = build(:subscription_plan, amount: nil) 13 | expect(subscription_plan.valid?).to be false 14 | end 15 | 16 | it "should validate interval" do 17 | subscription_plan = build(:subscription_plan, interval: nil) 18 | expect(subscription_plan.valid?).to be false 19 | end 20 | 21 | it "should validate stripe_id" do 22 | subscription_plan = build(:subscription_plan, stripe_id: nil) 23 | expect(subscription_plan.valid?).to be false 24 | end 25 | 26 | it "should validate name" do 27 | subscription_plan = build(:subscription_plan, name: nil) 28 | expect(subscription_plan.valid?).to be false 29 | end 30 | 31 | it "should create the plan at stripe before the model is created" do 32 | subscription_plan = build(:subscription_plan) 33 | Payola::CreatePlan.should_receive(:call) 34 | subscription_plan.save! 35 | end 36 | 37 | it "should not try to create the plan at stripe before the model is updated" do 38 | subscription_plan = build(:subscription_plan) 39 | subscription_plan.save! 40 | subscription_plan.name = "new name" 41 | 42 | Payola::CreatePlan.should_not_receive(:call) 43 | subscription_plan.save! 44 | end 45 | 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Adds additional error checking when serving assets at runtime. 31 | # Checks for improperly declared sprockets dependencies. 32 | # Raises helpful error messages. 33 | config.assets.raise_runtime_errors = true 34 | 35 | # Raises error for missing translations 36 | # config.action_view.raise_on_missing_translations = true 37 | end 38 | -------------------------------------------------------------------------------- /spec/services/payola/invoice_paid_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe InvoicePaid do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | it "should do nothing if the invoice has no charge" do 7 | # create a Payola::Subscription 8 | plan = create(:subscription_plan) 9 | 10 | customer = Stripe::Customer.create( 11 | email: 'foo', 12 | card: stripe_helper.generate_card_token, 13 | plan: plan.stripe_id 14 | ) 15 | 16 | sub = create(:subscription, plan: plan, stripe_customer_id: customer.id, stripe_id: customer.subscriptions.first.id) 17 | 18 | event = StripeMock.mock_webhook_event('invoice.payment_succeeded', subscription: sub.stripe_id, charge: nil) 19 | 20 | count = Payola::Sale.count 21 | 22 | Payola::InvoicePaid.call(event) 23 | 24 | expect(Payola::Sale.count).to eq count 25 | end 26 | 27 | it "should create a sale" do 28 | plan = create(:subscription_plan) 29 | customer = Stripe::Customer.create( 30 | email: 'foo', 31 | card: stripe_helper.generate_card_token, 32 | plan: plan.stripe_id 33 | ) 34 | 35 | sub = create(:subscription, plan: plan, stripe_customer_id: customer.id, stripe_id: customer.subscriptions.first.id) 36 | 37 | charge = Stripe::Charge.create 38 | event = StripeMock.mock_webhook_event('invoice.payment_succeeded', subscription: sub.stripe_id, charge: charge.id) 39 | 40 | count = Payola::Sale.count 41 | 42 | sale = Payola::InvoicePaid.call(event) 43 | 44 | expect(Payola::Sale.count).to eq count + 1 45 | 46 | expect(sale.finished?).to be true 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

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

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

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

63 |
64 |

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

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/mailers/payola/receipt_mailer.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ReceiptMailer < ActionMailer::Base 3 | add_template_helper ::ApplicationHelper 4 | helper Payola::PriceHelper 5 | 6 | def receipt(sale_guid) 7 | ActiveRecord::Base.connection_pool.with_connection do 8 | @sale = Payola::Sale.find_by(guid: sale_guid) 9 | @product = @sale.product 10 | 11 | if Payola.pdf_receipt 12 | require 'docverter' 13 | 14 | pdf = Docverter::Conversion.run do |c| 15 | c.from = 'html' 16 | c.to = 'pdf' 17 | c.content = render_to_string('payola/receipt_mailer/receipt_pdf.html') 18 | end 19 | attachments["receipt-#{@sale.guid}.pdf"] = pdf 20 | end 21 | 22 | mail_params = { 23 | to: @sale.email, 24 | from: Payola.support_email, 25 | subject: @product.respond_to?(:receipt_subject) ? @product.receipt_subject(@sale) : 'Purchase Receipt', 26 | } 27 | 28 | if @product.respond_to?(:receipt_from_address) 29 | mail_params[:from] = @product.receipt_from_address(@sale) 30 | end 31 | 32 | mail(mail_params) 33 | end 34 | end 35 | 36 | def refund(sale_guid) 37 | ActiveRecord::Base.connection_pool.with_connection do 38 | @sale = Payola::Sale.find_by(guid: sale_guid) 39 | @product = @sale.product 40 | 41 | mail_params = { 42 | to: @sale.email, 43 | from: Payola.support_email, 44 | subject: @product.respond_to?(:refund_subject) ? @product.refund_subject(@sale) : 'Refund Confirmation', 45 | } 46 | 47 | if @product.respond_to?(:refund_from_address) 48 | mail_params[:from] = @product.refund_from_address(@sale) 49 | end 50 | 51 | mail(mail_params) 52 | end 53 | end 54 | 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

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

63 |
64 |

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

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/services/payola/invoice_behavior.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Payola 4 | module InvoiceBehavior 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | def create_sale_from_event(event) 9 | invoice = event.data.object 10 | 11 | return unless invoice.charge 12 | 13 | subscription = Payola::Subscription.find_by!(stripe_id: invoice.subscription) 14 | secret_key = Payola.secret_key_for_sale(subscription) 15 | 16 | stripe_sub = Stripe::Customer.retrieve(subscription.stripe_customer_id, secret_key).subscriptions.retrieve(invoice.subscription, secret_key) 17 | subscription.sync_with!(stripe_sub) 18 | 19 | sale = create_sale(subscription, invoice) 20 | 21 | charge = Stripe::Charge.retrieve(invoice.charge, secret_key) 22 | 23 | update_sale_with_charge(sale, charge) 24 | 25 | return sale, charge 26 | end 27 | 28 | def create_sale(subscription, invoice) 29 | Payola::Sale.new do |s| 30 | s.email = subscription.email 31 | s.state = 'processing' 32 | s.owner = subscription 33 | s.product = subscription.plan 34 | s.stripe_token = 'invoice' 35 | s.amount = invoice.total 36 | s.currency = invoice.currency 37 | end 38 | end 39 | 40 | def update_sale_with_charge(sale, charge) 41 | sale.stripe_id = charge.id 42 | sale.card_type = charge.card.respond_to?(:brand) ? charge.card.brand : charge.card.type 43 | sale.card_last4 = charge.card.last4 44 | 45 | if charge.respond_to?(:fee) 46 | sale.fee_amount = charge.fee 47 | else 48 | balance = Stripe::BalanceTransaction.retrieve(charge.balance_transaction, secret_key) 49 | sale.fee_amount = balance.fee 50 | end 51 | end 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/services/payola/create_sale_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe CreateSale do 5 | before do 6 | @product = create(:product) 7 | end 8 | 9 | describe "#call" do 10 | it "should create a sale" do 11 | sale = CreateSale.call( 12 | stripeEmail: 'pete@bugsplat.info', 13 | stripeToken: 'test_tok', 14 | product: @product 15 | ) 16 | 17 | expect(sale.email).to eq 'pete@bugsplat.info' 18 | expect(sale.stripe_token).to eq 'test_tok' 19 | expect(sale.product_id).to eq @product.id 20 | expect(sale.product).to eq @product 21 | expect(sale.product_type).to eq 'Product' 22 | expect(sale.currency).to eq 'usd' 23 | end 24 | 25 | it "should include the affiliate if given" do 26 | affiliate = create(:payola_affiliate) 27 | sale = CreateSale.call( 28 | email: 'pete@bugsplat.info', 29 | stripeToken: 'test_tok', 30 | product: @product, 31 | affiliate: affiliate 32 | ) 33 | 34 | expect(sale.affiliate).to eq affiliate 35 | end 36 | 37 | describe "with coupon" do 38 | it "should include the coupon" do 39 | coupon = create(:payola_coupon) 40 | 41 | sale = CreateSale.call( 42 | email: 'pete@bugsplat.info', 43 | stripeToken: 'test_tok', 44 | product: @product, 45 | coupon: coupon 46 | ) 47 | 48 | expect(sale.coupon).to eq coupon 49 | end 50 | it "should set the price correctly" do 51 | coupon = create(:payola_coupon) 52 | 53 | sale = CreateSale.call( 54 | email: 'pete@bugsplat.info', 55 | stripeToken: 'test_tok', 56 | product: @product, 57 | coupon: coupon 58 | ) 59 | 60 | expect(sale.amount).to eq 99 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance. 16 | if Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 2 17 | config.serve_static_files = true 18 | else 19 | config.serve_static_asses = true 20 | end 21 | config.static_cache_control = 'public, max-age=3600' 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | 27 | # Raise exceptions instead of rendering exception templates. 28 | config.action_dispatch.show_exceptions = false 29 | 30 | # Disable request forgery protection in test environment. 31 | config.action_controller.allow_forgery_protection = false 32 | 33 | # Tell Action Mailer not to deliver emails to the real world. 34 | # The :test delivery method accumulates sent emails in the 35 | # ActionMailer::Base.deliveries array. 36 | config.action_mailer.delivery_method = :test 37 | 38 | # Print deprecation notices to the stderr. 39 | config.active_support.deprecation = :stderr 40 | 41 | # Raises error for missing translations 42 | # config.action_view.raise_on_missing_translations = true 43 | 44 | config.action_mailer.default_url_options = {host: 'www.example.com'} 45 | end 46 | -------------------------------------------------------------------------------- /spec/services/payola/charge_card_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe ChargeCard do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | describe "#call" do 7 | it "should create a customer" do 8 | sale = create(:sale, state: 'processing', stripe_token: stripe_helper.generate_card_token) 9 | ChargeCard.call(sale) 10 | expect(sale.reload.stripe_customer_id).to_not be_nil 11 | end 12 | 13 | it "should not create a customer if one already exists" do 14 | customer = Stripe::Customer.create 15 | sale = create(:sale, state: 'processing', stripe_customer_id: customer.id) 16 | expect(Stripe::Customer).to receive(:retrieve).and_return(customer) 17 | ChargeCard.call(sale) 18 | expect(sale.reload.stripe_customer_id).to eq customer.id 19 | expect(sale.state).to eq 'finished' 20 | end 21 | 22 | it "should create a charge" do 23 | sale = create(:sale, state: 'processing', stripe_token: stripe_helper.generate_card_token) 24 | ChargeCard.call(sale) 25 | expect(sale.reload.stripe_id).to_not be_nil 26 | expect(sale.reload.card_last4).to_not be_nil 27 | expect(sale.reload.card_expiration).to_not be_nil 28 | expect(sale.reload.card_type).to_not be_nil 29 | end 30 | 31 | it "should get the fee from the balance transaction" do 32 | sale = create(:sale, state: 'processing', stripe_token: stripe_helper.generate_card_token) 33 | ChargeCard.call(sale) 34 | expect(sale.reload.fee_amount).to_not be_nil 35 | end 36 | 37 | describe "on error" do 38 | it "should update the error attribute" do 39 | 40 | StripeMock.prepare_card_error(:card_declined) 41 | sale = create(:sale, state: 'processing', stripe_token: stripe_helper.generate_card_token) 42 | ChargeCard.call(sale) 43 | expect(sale.reload.error).to_not be_nil 44 | expect(sale.errored?).to be true 45 | end 46 | end 47 | end 48 | end 49 | end 50 | 51 | -------------------------------------------------------------------------------- /app/services/payola/charge_card.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class ChargeCard 3 | def self.call(sale) 4 | sale.save! 5 | secret_key = Payola.secret_key_for_sale(sale) 6 | 7 | begin 8 | sale.verify_charge! 9 | 10 | customer = create_customer(sale, secret_key) 11 | charge = create_charge(sale, customer, secret_key) 12 | 13 | update_sale(sale, customer, charge, secret_key) 14 | 15 | sale.finish! 16 | rescue Stripe::StripeError, RuntimeError => e 17 | sale.update_attributes(error: e.message) 18 | sale.fail! 19 | end 20 | 21 | sale 22 | end 23 | 24 | def self.create_customer(sale, secret_key) 25 | if sale.stripe_customer_id.present? 26 | Stripe::Customer.retrieve(sale.stripe_customer_id, secret_key) 27 | else 28 | Stripe::Customer.create({ 29 | card: sale.stripe_token, 30 | email: sale.email 31 | }, secret_key) 32 | end 33 | end 34 | 35 | def self.create_charge(sale, customer, secret_key) 36 | charge_attributes = { 37 | amount: sale.amount, 38 | currency: sale.currency, 39 | customer: customer.id, 40 | description: sale.guid, 41 | }.merge(Payola.additional_charge_attributes.call(sale, customer)) 42 | 43 | Stripe::Charge.create(charge_attributes, secret_key) 44 | end 45 | 46 | def self.update_sale(sale, customer, charge, secret_key) 47 | if charge.respond_to?(:fee) 48 | fee = charge.fee 49 | else 50 | balance = Stripe::BalanceTransaction.retrieve(charge.balance_transaction, secret_key) 51 | fee = balance.fee 52 | end 53 | 54 | sale.update_attributes( 55 | stripe_id: charge.id, 56 | stripe_customer_id: customer.id, 57 | card_last4: charge.card.last4, 58 | card_expiration: Date.new(charge.card.exp_year, charge.card.exp_month, 1), 59 | card_type: charge.card.respond_to?(:brand) ? charge.card.brand : charge.card.type, 60 | fee_amount: fee 61 | ) 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /app/views/payola/receipt_mailer/receipt_pdf.html.erb: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | PDF Receipt 6 | 32 | 33 | 34 |
35 |
36 |

37 | Transaction # <%= @sale.guid %>
38 | Created on <%= @sale.created_at.strftime('%Y-%m-%d') %>
39 |

40 |
41 | 42 |
43 |

44 | Customer Details
45 | Email: <%= @sale.email %>
46 | Payment Method: 47 | <% if @sale.stripe_id %> 48 | <%= @sale.card_type %> ending in <%= @sale.card_last4 %> 49 | <% else %> 50 | <%= @sale.stripe_token %> 51 | <% end %> 52 | <% if @sale.customer_address %> 53 |

54 | <%= @sale.customer_address.html_safe %> 55 | <% end %> 56 |

57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
DescriptionPriceAmount
<%= truncate(@product.name, length: 50) %><%= formatted_price(@product.price) %><%= formatted_price(@sale.amount) %>
 Total<%= formatted_price(@sale.amount) %>
75 |
76 |

77 | Thank you for your order! If you have any questions please email <%= Payola.support_email %>. 78 |

79 |
80 |
81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /spec/mailers/payola/receipt_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'docverter' 3 | 4 | module Payola 5 | describe ReceiptMailer do 6 | let(:sale) { create(:sale) } 7 | 8 | before do 9 | Payola.pdf_receipt = false 10 | end 11 | 12 | describe '#receipt' do 13 | it 'should send a receipt' do 14 | mail = Payola::ReceiptMailer.receipt(sale.guid) 15 | expect(mail.subject).to eq 'Purchase Receipt' 16 | end 17 | 18 | it 'should send a receipt with a pdf' do 19 | Payola.pdf_receipt = true 20 | expect(Docverter::Conversion).to receive(:run).and_return('pdf') 21 | mail = Payola::ReceiptMailer.receipt(sale.guid) 22 | expect(mail.attachments["receipt-#{sale.guid}.pdf"]).to_not be nil 23 | end 24 | 25 | it "should allow product to override subject" do 26 | expect_any_instance_of(Product).to receive(:receipt_subject).and_return("Override Subject") 27 | mail = Payola::ReceiptMailer.receipt(sale.guid) 28 | expect(mail.subject).to eq 'Override Subject' 29 | end 30 | 31 | it "should allow product to override from address" do 32 | expect_any_instance_of(Product).to receive(:receipt_from_address).and_return("Override ") 33 | mail = Payola::ReceiptMailer.receipt(sale.guid) 34 | expect(mail.from.first).to eq 'override@example.com' 35 | end 36 | end 37 | 38 | describe '#refund' do 39 | it "should send refund email" do 40 | mail = Payola::ReceiptMailer.refund(sale.guid) 41 | expect(mail.subject).to eq 'Refund Confirmation' 42 | end 43 | 44 | it "should allow product to override subject" do 45 | expect_any_instance_of(Product).to receive(:refund_subject).and_return("Override Subject") 46 | mail = Payola::ReceiptMailer.refund(sale.guid) 47 | expect(mail.subject).to eq 'Override Subject' 48 | end 49 | 50 | it "should allow product to override from address" do 51 | expect_any_instance_of(Product).to receive(:refund_from_address).and_return("Override ") 52 | mail = Payola::ReceiptMailer.refund(sale.guid) 53 | expect(mail.from.first).to eq 'override@example.com' 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Payola Changelog 2 | 3 | * v1.2.4 - 2015-01-06 4 | - Fix regressions in v1.2.3 5 | 6 | * v1.2.3 - 2015-01-03 7 | - Add support for Rails 4.2 8 | - Re-use customers and create invoice items for setup fees 9 | - Add an active flag on `Payola::Coupon` 10 | - Fix load-order problems 11 | - Add support for subscription quantities 12 | - Properly handle form errors 13 | 14 | * v1.2.2 - 2014-11-29 15 | - Optionally invert subscription controller flow 16 | - Fix the CSRF token behavior 17 | 18 | * v1.2.1 - 2014-11-20 19 | - Make guid generator overrideable 20 | - Bumped minimum version of AASM to 4.0 21 | - Fixed a bug with the auto emails not working for webhook events 22 | - Code cleanup 23 | - Test coverage improvements 24 | 25 | * v1.2.0 - 2014-11-17 26 | - Subscriptions 27 | 28 | * v1.1.4 - 2014-11-07 29 | - Pass the created customer to `additional_charge_attributes` 30 | - Add Payola Pro license 31 | 32 | * v1.1.3 - 2014-11-07 33 | - Add options for requesting billing and shipping addresses via Checkout 34 | - Add a callable to add additional attributes to a Stripe::Charge 35 | - Only talk about PDFs if PDFs are enabled 36 | 37 | * v1.1.2 - 2014-11-06 38 | - Default the `From` address on receipt emails to `Payola.support_email` 39 | 40 | * v1.1.1 - 2014-11-03 41 | - ActiveJob can't serialize a class or a symbol so we have to `to_s` them 42 | 43 | * v1.1.0 - 2014-11-03 44 | - Add customizable mailers 45 | - Pass currency through properly and add a `default_currency` config option 46 | - Use data attributes to set up the checkout button instead of a JS snippet 47 | - Add a polymorphic `owner` association on `Payola::Sale`. 48 | - Allow the price to be overridden on the Checkout form 49 | 50 | * v1.0.8 - 2014-10-27 51 | - Add basic support for custom forms 52 | - Allow passing signed data from checkout button into the charge verifier 53 | - Correctly pass the price into the Checkout button, which allows the `{{amount}}` macro to work properly 54 | - I18n the formatted_price helper 55 | 56 | * v1.0.7 - 2014-10-21 57 | - Add support for ActiveJob workers 58 | - Document how to set Stripe keys 59 | - Add a callback to fetch the publishable key 60 | - Unpin the Rails version to allow anything >= 4.1 61 | - Allow Payola to be mounted anywhere, as long as it has 'as: :payola' in the mount spec 62 | 63 | * v1.0.6 - 2014-10-19 64 | - First public release 65 | < 66 | -------------------------------------------------------------------------------- /spec/models/payola/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe Subscription do 5 | 6 | describe "validations" do 7 | it "should validate" do 8 | subscription = build(:subscription) 9 | expect(subscription.valid?).to be true 10 | end 11 | 12 | it "should validate plan" do 13 | subscription = build(:subscription, plan: nil) 14 | expect(subscription.valid?).to be false 15 | end 16 | 17 | it "should validate lack of email" do 18 | sale = build(:sale, email: nil) 19 | expect(sale.valid?).to be false 20 | end 21 | 22 | it "should validate stripe_token" do 23 | sale = build(:sale, stripe_token: nil) 24 | expect(sale.valid?).to be false 25 | end 26 | 27 | end 28 | 29 | describe "#sync_with!" do 30 | it "should sync timestamps" do 31 | plan = create(:subscription_plan) 32 | subscription = build(:subscription, plan: plan) 33 | stripe_sub = Stripe::Customer.create.subscriptions.create(plan: plan.stripe_id, card: StripeMock.generate_card_token(last4: '1234', exp_year: Time.now.year + 1)) 34 | old_start = subscription.current_period_start 35 | old_end = subscription.current_period_end 36 | trial_start = subscription.trial_start 37 | trial_end = subscription.trial_end 38 | 39 | now = Time.now.to_i 40 | expect(stripe_sub).to receive(:canceled_at).and_return(now).at_least(1) 41 | 42 | subscription.sync_with!(stripe_sub) 43 | 44 | subscription.reload 45 | 46 | expect(subscription.current_period_start).to eq Time.at(stripe_sub.current_period_start) 47 | expect(subscription.current_period_start).to_not eq old_start 48 | expect(subscription.current_period_end).to eq Time.at(stripe_sub.current_period_end) 49 | expect(subscription.current_period_end).to_not eq old_end 50 | expect(subscription.canceled_at).to eq Time.at(now) 51 | end 52 | 53 | it "should sync non-timestamp fields" do 54 | plan = create(:subscription_plan) 55 | subscription = build(:subscription, plan: plan) 56 | stripe_sub = Stripe::Customer.create.subscriptions.create(plan: plan.stripe_id, card: StripeMock.generate_card_token(last4: '1234', exp_year: Time.now.year + 1)) 57 | 58 | expect(stripe_sub).to receive(:quantity).and_return(10).at_least(1) 59 | expect(stripe_sub).to receive(:cancel_at_period_end).and_return(true).at_least(1) 60 | 61 | subscription.sync_with!(stripe_sub) 62 | 63 | subscription.reload 64 | 65 | expect(subscription.quantity).to eq 10 66 | expect(subscription.stripe_status).to eq 'active' 67 | expect(subscription.cancel_at_period_end).to eq true 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payola 2 | 3 | [![Gem Version](https://badge.fury.io/rb/payola-payments.svg)](http://badge.fury.io/rb/payola-payments) [![CircleCI](https://circleci.com/gh/peterkeen/payola.svg?style=shield)](https://circleci.com/gh/peterkeen/payola) [![Code Climate](https://codeclimate.com/github/peterkeen/payola/badges/gpa.svg)](https://codeclimate.com/github/peterkeen/payola) [![Test Coverage](https://codeclimate.com/github/peterkeen/payola/badges/coverage.svg)](https://codeclimate.com/github/peterkeen/payola) [![Dependency Status](https://gemnasium.com/peterkeen/payola.svg)](https://gemnasium.com/peterkeen/payola) 4 | 5 | Payments with Stripe for your Rails application. 6 | 7 | ## What does this do? 8 | 9 | Payola is a drop-in Rails engine that lets you sell one or more products by just including a module in your models. It includes: 10 | 11 | * An easy to embed, easy to customize, async Stripe Checkout button 12 | * Asynchronous payments, usable with any background processing system 13 | * Full webhook integration 14 | * Easy extension hooks for adding your own functionality 15 | * Customizable emails 16 | 17 | To see Payola in action, check out the site for [Mastering Modern Payments: Using Stripe with Rails](https://www.masteringmodernpayments.com). Read the book to find out the whys behind Payola's design. 18 | 19 | ## Installation 20 | 21 | Add Payola to your Gemfile: 22 | 23 | ```ruby 24 | gem 'payola-payments' 25 | ``` 26 | 27 | Run the installer and install the migrations: 28 | 29 | ```bash 30 | $ rails g payola:install 31 | $ rake db:migrate 32 | ``` 33 | 34 | ## Additional Setup Resources 35 | 36 | 37 | [One-time payments](https://github.com/peterkeen/payola/wiki/One-time-payments) 38 | 39 | [Configuration options](https://github.com/peterkeen/payola/wiki/Configuration-options) 40 | 41 | [Subscriptions](https://github.com/peterkeen/payola/wiki/Subscriptions) 42 | 43 | 44 | ## Upgrade to Pro 45 | 46 | I also sell **Payola Pro**, a collection of add-ons to Payola that enables things like drop-in Mailchimp and Mixpanel integration, as well as Stripe Connect support. It also comes with priority support and a lawyer-friendly commercial license. You can see all of the details on the [Payola Pro homepage](https://www.payola.io/pro). 47 | 48 | ## TODO 49 | 50 | * Multiple subscriptions per customer 51 | * Affiliate tracking 52 | * Easy metered billing 53 | 54 | ## License 55 | 56 | Please see the LICENSE file for licensing details. 57 | 58 | ## Contributing 59 | 60 | 1. Fork the project 61 | 2. Make your changes, including tests that exercise the code 62 | 3. Make a pull request 63 | 64 | Version announcements happen on the [Payola Payments Google group](https://groups.google.com/forum/#!forum/payola-payments) and [@payolapayments](https://twitter.com/payolapayments). 65 | 66 | ## Author 67 | 68 | Pete Keen, [@zrail](https://twitter.com/zrail), [https://www.petekeen.net](https://www.petekeen.net) 69 | 70 | -------------------------------------------------------------------------------- /app/controllers/payola/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class SubscriptionsController < ApplicationController 3 | include Payola::AffiliateBehavior 4 | include Payola::StatusBehavior 5 | include Payola::AsyncBehavior 6 | 7 | before_filter :find_plan_coupon_and_quantity, only: [:create, :change_plan] 8 | before_filter :check_modify_permissions, only: [:destroy, :change_plan, :change_quantity, :update_card] 9 | 10 | def show 11 | show_object(Subscription) 12 | end 13 | 14 | def status 15 | object_status(Subscription) 16 | end 17 | 18 | def create 19 | create_object(Subscription, CreateSubscription, nil, :plan, @plan) 20 | end 21 | 22 | def destroy 23 | subscription = Subscription.find_by!(guid: params[:guid]) 24 | Payola::CancelSubscription.call(subscription) 25 | redirect_to confirm_subscription_path(subscription) 26 | end 27 | 28 | def change_plan 29 | @subscription = Subscription.find_by!(guid: params[:guid]) 30 | Payola::ChangeSubscriptionPlan.call(@subscription, @plan) 31 | 32 | confirm_with_message("Subscription plan updated") 33 | end 34 | 35 | def change_quantity 36 | find_quantity 37 | @subscription = Subscription.find_by!(guid: params[:guid]) 38 | Payola::ChangeSubscriptionQuantity.call(@subscription, @quantity) 39 | 40 | confirm_with_message("Subscription quantity updated") 41 | end 42 | 43 | def update_card 44 | @subscription = Subscription.find_by!(guid: params[:guid]) 45 | Payola::UpdateCard.call(@subscription, params[:stripeToken]) 46 | 47 | confirm_with_message("Card updated") 48 | end 49 | 50 | private 51 | 52 | def find_plan_coupon_and_quantity 53 | find_plan 54 | find_coupon 55 | find_quantity 56 | end 57 | 58 | def find_plan 59 | @plan_class = Payola.subscribables[params[:plan_class]] 60 | 61 | raise ActionController::RoutingError.new('Not Found') unless @plan_class && @plan_class.subscribable? 62 | 63 | @plan = @plan_class.find_by!(id: params[:plan_id]) 64 | end 65 | 66 | def find_coupon 67 | @coupon = cookies[:cc] || params[:cc] || params[:coupon_code] || params[:coupon] 68 | end 69 | 70 | def find_quantity 71 | @quantity = params[:quantity].blank? ? 1 : params[:quantity].to_i 72 | end 73 | 74 | def check_modify_permissions 75 | subscription = Subscription.find_by!(guid: params[:guid]) 76 | if self.respond_to?(:payola_can_modify_subscription?) 77 | redirect_to( 78 | confirm_subscription_path(subscription), 79 | alert: "You cannot modify this subscription." 80 | ) and return unless self.payola_can_modify_subscription?(subscription) 81 | end 82 | end 83 | 84 | def confirm_with_message(message) 85 | if @subscription.valid? 86 | redirect_to confirm_subscription_path(@subscription), notice: message 87 | else 88 | redirect_to confirm_subscription_path(@subscription), alert: @subscription.errors.full_messages.to_sentence 89 | end 90 | end 91 | 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/models/payola/sale_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe Sale do 5 | 6 | before do 7 | Payola.secret_key = 'sk_test_12345' 8 | end 9 | 10 | describe "validations" do 11 | it "should validate" do 12 | sale = build(:sale) 13 | expect(sale.valid?).to be true 14 | end 15 | 16 | it "should validate lack of email" do 17 | sale = build(:sale, email: nil) 18 | expect(sale.valid?).to be false 19 | end 20 | it "should validate product" do 21 | sale = build(:sale, product: nil) 22 | expect(sale.valid?).to be false 23 | end 24 | it "should validate stripe_token" do 25 | sale = build(:sale, stripe_token: nil) 26 | expect(sale.valid?).to be false 27 | end 28 | end 29 | 30 | describe "#guid" do 31 | it "should generate a unique guid" do 32 | sale = create(:sale) 33 | expect(sale.valid?).to be true 34 | expect(sale.guid).to_not be_nil 35 | 36 | sale2 = build(:sale, guid: sale.guid) 37 | expect(sale2.valid?).to be false 38 | end 39 | end 40 | 41 | describe "#process!" do 42 | it "should charge the card" do 43 | Payola::ChargeCard.should_receive(:call) 44 | 45 | sale = create(:sale) 46 | sale.process! 47 | end 48 | end 49 | 50 | describe "#finish" do 51 | it "should instrument finish" do 52 | sale = create(:sale, state: 'processing') 53 | Payola.should_receive(:instrument).with('payola.product.sale.finished', sale) 54 | Payola.should_receive(:instrument).with('payola.sale.finished', sale) 55 | 56 | sale.finish! 57 | end 58 | end 59 | 60 | describe "#fail" do 61 | it "should instrument fail" do 62 | sale = create(:sale, state: 'processing') 63 | Payola.should_receive(:instrument).with('payola.product.sale.failed', sale) 64 | Payola.should_receive(:instrument).with('payola.sale.failed', sale) 65 | 66 | sale.fail! 67 | end 68 | end 69 | 70 | describe "#refund" do 71 | it "should instrument refund" do 72 | sale = create(:sale, state: 'finished') 73 | Payola.should_receive(:instrument).with('payola.product.sale.refunded', sale) 74 | Payola.should_receive(:instrument).with('payola.sale.refunded', sale) 75 | sale.refund! 76 | end 77 | end 78 | 79 | describe "#verifier" do 80 | it "should store and recall verified custom fields" do 81 | sale = create(:sale) 82 | sale.signed_custom_fields = sale.verifier.generate({"field" => "value"}) 83 | sale.save! 84 | sale.reload 85 | expect(sale.custom_fields["field"]).to eq "value" 86 | end 87 | end 88 | 89 | describe "#owner" do 90 | it "should store and recall owner" do 91 | sale = create(:sale) 92 | owner = Owner.create 93 | 94 | sale.owner = owner 95 | sale.save! 96 | 97 | expect(sale.owner_id).to eq owner.id 98 | expect(sale.owner_type).to eq 'Owner' 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /app/views/payola/transactions/_checkout.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | button_class = local_assigns.fetch :button_class, "stripe-button-el" 3 | button_inner_style = local_assigns.fetch :button_inner_style, 'min-height: 30px' 4 | button_text = local_assigns.fetch :button_text, "Pay Now" 5 | description = local_assigns.fetch :description, "#{sellable.name} (#{formatted_price(sellable.price)})" 6 | name = local_assigns.fetch :name, sellable.name 7 | product_image_path = local_assigns.fetch :product_image_path , '' 8 | panel_label = local_assigns.fetch :panel_label, '' 9 | allow_remember_me = local_assigns.fetch :allow_remember_me, true 10 | email = local_assigns.fetch :email, '' 11 | verify_zip_code = local_assigns.fetch :verify_zip_code, false 12 | custom_fields = local_assigns.fetch :custom_fields, nil 13 | price = local_assigns.fetch :price, sellable.price 14 | billing_address = local_assigns.fetch :billing_address, false 15 | shipping_address = local_assigns.fetch :shipping_address, false 16 | 17 | sale = Payola::Sale.new(product: sellable) 18 | 19 | button_id = "payola-button-#{sellable.product_class}-#{sellable.permalink}" 20 | 21 | form_id = "#{button_id}-form" 22 | 23 | currency = sellable.respond_to?(:currency) ? sellable.currency : Payola.default_currency 24 | 25 | error_div_id = local_assigns.fetch :error_div_id, '' 26 | if error_div_id.present? 27 | show_default_error_div = false 28 | else 29 | error_div_id = "#{button_id}-errors" 30 | show_default_error_div = true 31 | end 32 | 33 | raw_data = { 34 | base_path: main_app.payola_path, 35 | form_id: form_id, 36 | button_id: button_id, 37 | error_div_id: error_div_id, 38 | product_class: sellable.product_class, 39 | product_permalink: sellable.permalink, 40 | price: price, 41 | name: name, 42 | description: description, 43 | currency: currency, 44 | product_image_path: product_image_path, 45 | publishable_key: Payola.publishable_key_for_sale(sale), 46 | panel_label: panel_label, 47 | allow_remember_me: allow_remember_me, 48 | email: email, 49 | verify_zip_code: verify_zip_code, 50 | billing_address: billing_address, 51 | shipping_address: shipping_address 52 | } 53 | 54 | raw_data[:signed_custom_fields] = sale.verifier.generate(custom_fields) if custom_fields 55 | 56 | html_hash = {} 57 | raw_data.each do |k,v| 58 | html_hash["data-#{k}"] = v 59 | end 60 | html_hash["id"] = form_id 61 | %> 62 | 63 | 64 | 65 | 66 | <%= form_tag payola.buy_path(product_class: sellable.product_class, permalink: sellable.permalink), html_hash do %> 67 | 71 | <% if show_default_error_div %> 72 | 73 | <% end %> 74 | <% end %> 75 | -------------------------------------------------------------------------------- /app/models/payola/sale.rb: -------------------------------------------------------------------------------- 1 | require 'aasm' 2 | 3 | module Payola 4 | class Sale < ActiveRecord::Base 5 | include Payola::GuidBehavior 6 | 7 | has_paper_trail if respond_to? :has_paper_trail 8 | 9 | validates_presence_of :email 10 | validates_presence_of :product_id 11 | validates_presence_of :product_type 12 | validates_presence_of :stripe_token 13 | validates_presence_of :currency 14 | 15 | belongs_to :product, polymorphic: true 16 | belongs_to :owner, polymorphic: true 17 | belongs_to :coupon 18 | belongs_to :affiliate 19 | 20 | include AASM 21 | 22 | aasm column: 'state', skip_validation_on_save: true do 23 | state :pending, initial: true 24 | state :processing 25 | state :finished 26 | state :errored 27 | state :refunded 28 | 29 | event :process, after: :charge_card do 30 | transitions from: :pending, to: :processing 31 | end 32 | 33 | event :finish, after: :instrument_finish do 34 | transitions from: :processing, to: :finished 35 | end 36 | 37 | event :fail, after: :instrument_fail do 38 | transitions from: [:pending, :processing], to: :errored 39 | end 40 | 41 | event :refund, after: :instrument_refund do 42 | transitions from: :finished, to: :refunded 43 | end 44 | end 45 | 46 | def verifier 47 | @verifier ||= ActiveSupport::MessageVerifier.new(Payola.secret_key_for_sale(self), digest: 'SHA256') 48 | end 49 | 50 | def verify_charge 51 | begin 52 | self.verify_charge! 53 | rescue RuntimeError => e 54 | self.error = e.message 55 | self.fail! 56 | end 57 | end 58 | 59 | def verify_charge! 60 | if Payola.charge_verifier.arity > 1 61 | Payola.charge_verifier.call(self, custom_fields) 62 | else 63 | Payola.charge_verifier.call(self) 64 | end 65 | end 66 | 67 | def custom_fields 68 | if self.signed_custom_fields.present? 69 | verifier.verify(self.signed_custom_fields) 70 | else 71 | nil 72 | end 73 | end 74 | 75 | def redirector 76 | product 77 | end 78 | 79 | private 80 | 81 | def charge_card 82 | Payola::ChargeCard.call(self) 83 | end 84 | 85 | def instrument_finish 86 | Payola.instrument(instrument_key('finished'), self) 87 | Payola.instrument(instrument_key('finished', false), self) 88 | end 89 | 90 | def instrument_fail 91 | Payola.instrument(instrument_key('failed'), self) 92 | Payola.instrument(instrument_key('failed', false), self) 93 | end 94 | 95 | def instrument_refund 96 | Payola.instrument(instrument_key('refunded'), self) 97 | Payola.instrument(instrument_key('refunded', false), self) 98 | end 99 | 100 | def product_class 101 | product.product_class 102 | end 103 | 104 | def instrument_key(instrument_type, include_class=true) 105 | if include_class 106 | "payola.#{product_class}.sale.#{instrument_type}" 107 | else 108 | "payola.sale.#{instrument_type}" 109 | end 110 | end 111 | 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /app/services/payola/start_subscription.rb: -------------------------------------------------------------------------------- 1 | module Payola 2 | class StartSubscription 3 | attr_reader :subscription, :secret_key 4 | 5 | def self.call(subscription) 6 | subscription.save! 7 | secret_key = Payola.secret_key_for_sale(subscription) 8 | 9 | new(subscription, secret_key).run 10 | end 11 | 12 | def initialize(subscription, secret_key) 13 | @subscription = subscription 14 | @secret_key = secret_key 15 | end 16 | 17 | def run 18 | begin 19 | subscription.verify_charge! 20 | 21 | customer = find_or_create_customer 22 | 23 | create_params = { 24 | plan: subscription.plan.stripe_id, 25 | quantity: subscription.quantity 26 | } 27 | create_params[:coupon] = subscription.coupon if subscription.coupon.present? 28 | stripe_sub = customer.subscriptions.create(create_params) 29 | 30 | card = customer.cards.data.first 31 | subscription.update_attributes( 32 | stripe_id: stripe_sub.id, 33 | stripe_customer_id: customer.id, 34 | card_last4: card.last4, 35 | card_expiration: Date.new(card.exp_year, card.exp_month, 1), 36 | card_type: card.respond_to?(:brand) ? card.brand : card.type, 37 | current_period_start: Time.at(stripe_sub.current_period_start), 38 | current_period_end: Time.at(stripe_sub.current_period_end), 39 | ended_at: stripe_sub.ended_at ? Time.at(stripe_sub.ended_at) : nil, 40 | trial_start: stripe_sub.trial_start ? Time.at(stripe_sub.trial_start) : nil, 41 | trial_end: stripe_sub.trial_end ? Time.at(stripe_sub.trial_end) : nil, 42 | canceled_at: stripe_sub.canceled_at ? Time.at(stripe_sub.canceled_at) : nil, 43 | quantity: stripe_sub.quantity, 44 | stripe_status: stripe_sub.status, 45 | cancel_at_period_end: stripe_sub.cancel_at_period_end 46 | ) 47 | subscription.activate! 48 | rescue Stripe::StripeError, RuntimeError => e 49 | subscription.update_attributes(error: e.message) 50 | subscription.fail! 51 | end 52 | 53 | subscription 54 | end 55 | 56 | def find_or_create_customer 57 | subs = Subscription.where(owner: subscription.owner) if subscription.owner 58 | if subs && subs.length > 1 59 | first_sub = subs.first 60 | customer_id = first_sub.stripe_customer_id 61 | return Stripe::Customer.retrieve(customer_id, secret_key) 62 | else 63 | customer_create_params = { 64 | card: subscription.stripe_token, 65 | email: subscription.email 66 | } 67 | 68 | customer = Stripe::Customer.create(customer_create_params, secret_key) 69 | end 70 | 71 | if subscription.setup_fee.present? 72 | plan = subscription.plan 73 | description = plan.try(:setup_fee_description, subscription) || 'Setup Fee' 74 | Stripe::InvoiceItem.create({ 75 | customer: customer.id, 76 | amount: subscription.setup_fee, 77 | currency: subscription.currency, 78 | description: description 79 | }, secret_key) 80 | end 81 | 82 | customer 83 | end 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /spec/controllers/payola/transactions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe TransactionsController do 5 | routes { Payola::Engine.routes } 6 | 7 | before do 8 | @product = create(:product) 9 | Payola.register_sellable(@product.class) 10 | end 11 | 12 | describe '#create' do 13 | it "should pass args to CreateSale and queue the job" do 14 | sale = double 15 | errors = double 16 | errors.should_receive(:full_messages).and_return([]) 17 | sale.should_receive(:state).and_return('pending') 18 | sale.should_receive(:error).and_return(nil) 19 | sale.should_receive(:errors).and_return(errors) 20 | sale.should_receive(:save).and_return(true) 21 | sale.should_receive(:guid).at_least(1).times.and_return('blah') 22 | 23 | CreateSale.should_receive(:call).with( 24 | 'product_class' => 'product', 25 | 'permalink' => @product.permalink, 26 | 'controller' => 'payola/transactions', 27 | 'action' => 'create', 28 | 'product' => @product, 29 | 'coupon' => nil, 30 | 'affiliate' => nil 31 | ).and_return(sale) 32 | 33 | Payola.should_receive(:queue!) 34 | post :create, product_class: @product.product_class, permalink: @product.permalink 35 | 36 | expect(response.status).to eq 200 37 | parsed_body = JSON.load(response.body) 38 | expect(parsed_body['guid']).to eq 'blah' 39 | end 40 | 41 | describe "with an error" do 42 | it "should return an error in json" do 43 | sale = double 44 | sale.should_receive(:error).and_return(nil) 45 | sale.should_receive(:save).and_return(false) 46 | sale.should_receive(:state).and_return('failed') 47 | sale.should_receive(:guid).at_least(1).times.and_return('blah') 48 | error = double 49 | error.should_receive(:full_messages).and_return(['done did broke']) 50 | sale.should_receive(:errors).and_return(error) 51 | 52 | CreateSale.should_receive(:call).and_return(sale) 53 | Payola.should_not_receive(:queue!) 54 | 55 | post :create, product_class: @product.product_class, permalink: @product.permalink 56 | 57 | expect(response.status).to eq 400 58 | parsed_body = JSON.load(response.body) 59 | expect(parsed_body['error']).to eq 'done did broke' 60 | end 61 | end 62 | end 63 | 64 | describe '#status' do 65 | it "should return 404 if it can't find the sale" do 66 | get :status, guid: 'doesnotexist' 67 | expect(response.status).to eq 404 68 | end 69 | it "should return json with properties" do 70 | sale = create(:sale) 71 | get :status, guid: sale.guid 72 | 73 | expect(response.status).to eq 200 74 | 75 | parsed_body = JSON.load(response.body) 76 | 77 | expect(parsed_body['guid']).to eq sale.guid 78 | expect(parsed_body['status']).to eq sale.state 79 | expect(parsed_body['error']).to be_nil 80 | end 81 | end 82 | 83 | describe '#show' do 84 | it "should redirect to the product's redirect path" do 85 | sale = create(:sale) 86 | get :show, guid: sale.guid 87 | 88 | expect(response).to redirect_to '/' 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola/form.js: -------------------------------------------------------------------------------- 1 | var PayolaPaymentForm = { 2 | initialize: function() { 3 | $(document).on('submit', '.payola-payment-form', function() { 4 | return PayolaPaymentForm.handleSubmit($(this)); 5 | }); 6 | }, 7 | 8 | handleSubmit: function(form) { 9 | form.find(':submit').prop('disabled', true); 10 | $('.payola-spinner').show(); 11 | Stripe.card.createToken(form, function(status, response) { 12 | PayolaPaymentForm.stripeResponseHandler(form, status, response); 13 | }); 14 | return false; 15 | }, 16 | 17 | stripeResponseHandler: function(form, status, response) { 18 | if (response.error) { 19 | PayolaPaymentForm.showError(form, response.error.message); 20 | } else { 21 | var email = form.find("[data-payola='email']").val(); 22 | 23 | var base_path = form.data('payola-base-path'); 24 | var product = form.data('payola-product'); 25 | var permalink = form.data('payola-permalink'); 26 | 27 | var data_form = $('
'); 28 | data_form.append($('').val(response.id)); 29 | data_form.append($('').val(email)); 30 | data_form.append(PayolaPaymentForm.authenticityTokenInput()); 31 | 32 | $.ajax({ 33 | type: "POST", 34 | url: base_path + "/buy/" + product + "/" + permalink, 35 | data: data_form.serialize(), 36 | success: function(data) { PayolaPaymentForm.poll(form, 60, data.guid, base_path); }, 37 | error: function(data) { PayolaPaymentForm.showError(form, data.responseJSON.error); } 38 | }); 39 | } 40 | }, 41 | 42 | poll: function(form, num_retries_left, guid, base_path) { 43 | if (num_retries_left === 0) { 44 | PayolaPaymentForm.showError(form, "This seems to be taking too long. Please contact support and give them transaction ID: " + guid); 45 | } 46 | $.get(base_path + '/status/' + guid, function(data) { 47 | if (data.status === "finished") { 48 | form.append($('').val(guid)); 49 | form.append(PayolaPaymentForm.authenticityTokenInput()); 50 | form.get(0).submit(); 51 | } else if (data.status === "errored") { 52 | PayolaPaymentForm.showError(form, data.error); 53 | } else { 54 | setTimeout(function() { PayolaPaymentForm.poll(form, num_retries_left - 1, guid, base_path); }, 500); 55 | } 56 | }); 57 | }, 58 | 59 | showError: function(form, message) { 60 | $('.payola-spinner').hide(); 61 | form.find(':submit').prop('disabled', false); 62 | var error_selector = form.data('payola-error-selector'); 63 | if (error_selector) { 64 | $(error_selector).text(message); 65 | } else { 66 | form.find('.payola-payment-error').text(message); 67 | } 68 | }, 69 | 70 | authenticityTokenInput: function() { 71 | return $('').val($('meta[name="csrf-token"]').attr("content")) 72 | } 73 | }; 74 | 75 | $(function() { PayolaPaymentForm.initialize(); } ); 76 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require "codeclimate-test-reporter" 3 | 4 | if ENV['CIRCLE_ARTIFACTS'] 5 | dir = File.join("..", "..", "..", ENV['CIRCLE_ARTIFACTS'], "coverage") 6 | SimpleCov.coverage_dir(dir) 7 | CodeClimate::TestReporter.start 8 | end 9 | SimpleCov.start 'rails' do 10 | add_filter 'app/secrets' 11 | end 12 | 13 | 14 | # This file is copied to spec/ when you run 'rails generate rspec:install' 15 | ENV["RAILS_ENV"] ||= 'test' 16 | require File.expand_path("../dummy/config/environment", __FILE__) 17 | require 'rspec/rails' 18 | require 'rspec/autorun' 19 | require 'factory_girl_rails' 20 | require 'stripe_mock' 21 | 22 | # Requires supporting ruby files with custom matchers and macros, etc, in 23 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 24 | # run as spec files by default. This means that files in spec/support that end 25 | # in _spec.rb will both be required and run as specs, causing the specs to be 26 | # run twice. It is recommended that you do not name files matching this glob to 27 | # end with _spec.rb. You can configure this pattern with with the --pattern 28 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 29 | Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } 30 | 31 | # Checks for pending migrations before tests are run. 32 | # If you are not using ActiveRecord, you can remove this line. 33 | ActiveRecord::Migration.maintain_test_schema! 34 | 35 | RSpec.configure do |config| 36 | # ## Mock Framework 37 | # 38 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 39 | # 40 | # config.mock_with :mocha 41 | # config.mock_with :flexmock 42 | # config.mock_with :rr 43 | 44 | config.mock_with :rspec 45 | 46 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 47 | # examples within a transaction, remove the following line or assign false 48 | # instead of true. 49 | config.use_transactional_fixtures = true 50 | 51 | # If true, the base class of anonymous controllers will be inferred 52 | # automatically. This will be the default behavior in future versions of 53 | # rspec-rails. 54 | config.infer_base_class_for_anonymous_controllers = false 55 | 56 | # Run specs in random order to surface order dependencies. If you find an 57 | # order dependency and want to debug it, you can fix the order by providing 58 | # the seed, which is printed after each run. 59 | # --seed 1234 60 | config.order = "random" 61 | 62 | # RSpec Rails can automatically mix in different behaviours to your tests 63 | # based on their file location, for example enabling you to call `get` and 64 | # `post` in specs under `spec/controllers`. 65 | # 66 | # You can disable this behaviour by removing the line below, and instead 67 | # explictly tag your specs with their type, e.g.: 68 | # 69 | # describe UsersController, :type => :controller do 70 | # # ... 71 | # end 72 | # 73 | # The different available types are documented in the features, such as in 74 | # https://relishapp.com/rspec/rspec-rails/v/3-0/docs 75 | config.infer_spec_type_from_file_location! 76 | 77 | config.include FactoryGirl::Syntax::Methods 78 | 79 | config.before(:each) do 80 | StripeMock.start 81 | end 82 | 83 | config.after(:each) do 84 | StripeMock.stop 85 | end 86 | end 87 | 88 | module Payola 89 | class FakeWorker 90 | def self.can_run? 91 | false 92 | end 93 | 94 | def self.call(*args) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/services/payola/start_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe StartSubscription do 5 | let(:stripe_helper) { StripeMock.create_test_helper } 6 | let(:token){ StripeMock.generate_card_token({}) } 7 | let(:user){ User.create } 8 | 9 | describe "#call" do 10 | it "should create a customer" do 11 | plan = create(:subscription_plan) 12 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token) 13 | StartSubscription.call(subscription) 14 | expect(subscription.reload.stripe_customer_id).to_not be_nil 15 | end 16 | it "should capture credit card info" do 17 | plan = create(:subscription_plan) 18 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token) 19 | StartSubscription.call(subscription) 20 | expect(subscription.reload.stripe_id).to_not be_nil 21 | expect(subscription.reload.card_last4).to_not be_nil 22 | expect(subscription.reload.card_expiration).to_not be_nil 23 | expect(subscription.reload.card_type).to_not be_nil 24 | end 25 | describe "on error" do 26 | it "should update the error attribute" do 27 | StripeMock.prepare_card_error(:card_declined, :new_customer) 28 | plan = create(:subscription_plan) 29 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token) 30 | StartSubscription.call(subscription) 31 | expect(subscription.reload.error).to_not be_nil 32 | expect(subscription.errored?).to be true 33 | end 34 | end 35 | 36 | it "should re-use an existing customer" do 37 | plan = create(:subscription_plan) 38 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token, owner: user) 39 | StartSubscription.call(subscription) 40 | CancelSubscription.call(subscription) 41 | 42 | subscription2 = create(:subscription, state: 'processing', plan: plan, owner: user) 43 | StartSubscription.call(subscription2) 44 | expect(subscription2.reload.stripe_customer_id).to_not be_nil 45 | expect(subscription2.reload.stripe_customer_id).to eq subscription.reload.stripe_customer_id 46 | end 47 | 48 | it "should create an invoice item with a setup fee" do 49 | plan = create(:subscription_plan) 50 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token, owner: user, setup_fee: 100) 51 | StartSubscription.call(subscription) 52 | 53 | ii = Stripe::InvoiceItem.all(customer: subscription.stripe_customer_id).first 54 | expect(ii).to_not be_nil 55 | expect(ii.amount).to eq 100 56 | expect(ii.description).to eq "Setup Fee" 57 | end 58 | 59 | it "should allow the plan to override the setup fee description" do 60 | plan = create(:subscription_plan) 61 | subscription = create(:subscription, state: 'processing', plan: plan, stripe_token: token, owner: user, setup_fee: 100) 62 | 63 | expect(plan).to receive(:setup_fee_description).with(subscription).and_return('Random Mystery Fee') 64 | StartSubscription.call(subscription) 65 | 66 | ii = Stripe::InvoiceItem.all(customer: subscription.stripe_customer_id).first 67 | expect(ii).to_not be_nil 68 | expect(ii.amount).to eq 100 69 | expect(ii.description).to eq 'Random Mystery Fee' 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | # Disable Rails's static asset server (Apache or nginx will already do this). 23 | config.serve_static_assets = false 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Generate digests for assets URLs. 33 | config.assets.digest = true 34 | 35 | # `config.assets.precompile` has moved to config/initializers/assets.rb 36 | 37 | # Specifies the header that your server uses for sending files. 38 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 39 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 40 | 41 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 42 | # config.force_ssl = true 43 | 44 | # Set to :debug to see everything in the log. 45 | config.log_level = :info 46 | 47 | # Prepend all log lines with the following tags. 48 | # config.log_tags = [ :subdomain, :uuid ] 49 | 50 | # Use a different logger for distributed setups. 51 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 52 | 53 | # Use a different cache store in production. 54 | # config.cache_store = :mem_cache_store 55 | 56 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 57 | # config.action_controller.asset_host = "http://assets.example.com" 58 | 59 | # Precompile additional assets. 60 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 61 | # config.assets.precompile += %w( search.js ) 62 | 63 | # Ignore bad email addresses and do not raise email delivery errors. 64 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 65 | # config.action_mailer.raise_delivery_errors = false 66 | 67 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 68 | # the I18n.default_locale when a translation cannot be found). 69 | config.i18n.fallbacks = true 70 | 71 | # Send deprecation notices to registered listeners. 72 | config.active_support.deprecation = :notify 73 | 74 | # Disable automatic flushing of the log to improve performance. 75 | # config.autoflush_log = false 76 | 77 | # Use default logging formatter so that PID and timestamp are not suppressed. 78 | config.log_formatter = ::Logger::Formatter.new 79 | 80 | # Do not dump schema after migrations. 81 | config.active_record.dump_schema_after_migration = false 82 | end 83 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola/checkout_button.js: -------------------------------------------------------------------------------- 1 | var PayolaCheckout = { 2 | initialize: function() { 3 | $(document).on('click', '.payola-checkout-button', function(e) { 4 | e.preventDefault(); 5 | PayolaCheckout.handleCheckoutButtonClick($(this)); 6 | }); 7 | }, 8 | 9 | handleCheckoutButtonClick: function(button) { 10 | var form = button.parent('form'); 11 | var options = form.data(); 12 | 13 | var handler = StripeCheckout.configure({ 14 | key: options.publishable_key, 15 | image: options.product_image_path, 16 | token: function(token) { PayolaCheckout.tokenHandler(token, options); }, 17 | name: options.name, 18 | description: options.description, 19 | amount: options.price, 20 | panelLabel: options.panel_label, 21 | allowRememberMe: options.allow_remember_me, 22 | zipCode: options.verify_zip_code, 23 | billingAddress: options.billing_address, 24 | shippingAddress: options.shipping_address, 25 | currency: options.currency, 26 | email: options.email || undefined 27 | }); 28 | 29 | handler.open(); 30 | }, 31 | 32 | tokenHandler: function(token, options) { 33 | var form = $("#" + options.form_id); 34 | console.log(options.form_id); 35 | form.append($('').val(token.id)); 36 | form.append($('').val(token.email)); 37 | if (options.signed_custom_fields) { 38 | form.append($('').val(options.signed_custom_fields)); 39 | } 40 | 41 | $(".payola-checkout-button").prop("disabled", true); 42 | $(".payola-checkout-button-text").hide(); 43 | $(".payola-checkout-button-spinner").show(); 44 | $.ajax({ 45 | type: "POST", 46 | url: options.base_path + "/buy/" + options.product_class + "/" + options.product_permalink, 47 | data: form.serialize(), 48 | success: function(data) { PayolaCheckout.poll(data.guid, 60, options); }, 49 | error: function(data) { PayolaCheckout.showError(data.responseJSON.error, options); } 50 | }); 51 | }, 52 | 53 | showError: function(error, options) { 54 | var error_div = $("#" + options.error_div_id); 55 | error_div.html(error); 56 | error_div.show(); 57 | $(".payola-checkout-button").prop("disabled", false); 58 | $(".payola-checkout-button-spinner").hide(); 59 | $(".payola-checkout-button-text").show(); 60 | }, 61 | 62 | poll: function(guid, num_retries_left, options) { 63 | if (num_retries_left === 0) { 64 | PayolaCheckout.showError("This seems to be taking too long. Please contact support and give them transaction ID: " + guid, options); 65 | return; 66 | } 67 | 68 | var handler = function(data) { 69 | if (data.status === "finished") { 70 | window.location = options.base_path + "/confirm/" + guid; 71 | } else if (data.status === "errored") { 72 | PayolaCheckout.showError(data.error, options); 73 | } else { 74 | setTimeout(function() { PayolaCheckout.poll(guid, num_retries_left - 1, options); }, 500); 75 | } 76 | }; 77 | 78 | $.ajax({ 79 | type: "GET", 80 | url: options.base_path + "/status/" + guid, 81 | success: handler, 82 | error: handler 83 | }); 84 | } 85 | }; 86 | $(function() { PayolaCheckout.initialize(); }); 87 | -------------------------------------------------------------------------------- /spec/worker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | 5 | class TestService 6 | def self.call(thing) 7 | thing.guid 8 | end 9 | end 10 | 11 | describe Worker do 12 | describe "#autofind" do 13 | it "should return ActiveJob if available" do 14 | expect(Payola::Worker::ActiveJob).to receive(:can_run?).and_return(true) 15 | expect(Payola::Worker.autofind).to eq Payola::Worker::ActiveJob 16 | end 17 | it "should return something else if available" do 18 | expect(Payola::Worker::ActiveJob).to receive(:can_run?).and_return(false) 19 | expect(Payola::Worker::Sidekiq).to receive(:can_run?).and_return(false) 20 | expect(Payola::Worker::SuckerPunch).to receive(:can_run?).and_return(true) 21 | expect(Payola::Worker.autofind).to eq Payola::Worker::SuckerPunch 22 | end 23 | 24 | it "should raise if nothing available" do 25 | expect(Payola::Worker::ActiveJob).to receive(:can_run?).and_return(false).at_least(1) 26 | expect(Payola::Worker::Sidekiq).to receive(:can_run?).and_return(false) 27 | expect(Payola::Worker::SuckerPunch).to receive(:can_run?).and_return(false) 28 | 29 | expect { Payola::Worker.autofind }.to raise_error("No eligible background worker systems found.") 30 | end 31 | end 32 | end 33 | 34 | describe Worker::Sidekiq do 35 | before do 36 | module ::Sidekiq 37 | module Worker 38 | def perform_async 39 | end 40 | end 41 | end 42 | end 43 | 44 | describe "#can_run?" do 45 | it "should return true if ::Sidekiq::Worker is defined" do 46 | expect(Payola::Worker::Sidekiq.can_run?).to be_truthy 47 | end 48 | end 49 | 50 | describe "#call" do 51 | it "should call perform_async" do 52 | Payola::Worker::Sidekiq.should_receive(:perform_async) 53 | Payola::Worker::Sidekiq.call(Payola::TestService, double) 54 | end 55 | end 56 | end 57 | 58 | describe Worker::SuckerPunch do 59 | describe "#can_run?" do 60 | it "should return true if SuckerPunch is defined" do 61 | expect(Payola::Worker::SuckerPunch.can_run?).to be_truthy 62 | end 63 | end 64 | 65 | describe "#call" do 66 | it "should call async" do 67 | worker = double 68 | expect(Payola::Worker::SuckerPunch).to receive(:new).and_return(worker) 69 | expect(worker).to receive(:async).and_return(worker) 70 | expect(worker).to receive(:perform) 71 | Payola::Worker::SuckerPunch.call(Payola::TestService, double) 72 | end 73 | end 74 | end 75 | 76 | describe Worker::ActiveJob do 77 | before do 78 | module ::ActiveJob 79 | module Core; end 80 | end 81 | end 82 | 83 | describe "#can_run?" do 84 | it "should return true if ::ActiveJob::Core is defined" do 85 | expect(Payola::Worker::ActiveJob.can_run?).to be_truthy 86 | end 87 | end 88 | 89 | describe "#call" do 90 | it "should call perform_later" do 91 | Payola::Worker::ActiveJob.should_receive(:perform_later) 92 | Payola::Worker::ActiveJob.call(Payola::TestService, double) 93 | end 94 | end 95 | end 96 | 97 | describe Worker::BaseWorker do 98 | 99 | class SomeTestService 100 | def self.call; end 101 | end 102 | 103 | describe "#perform" do 104 | it "should call the given service" do 105 | expect(SomeTestService).to receive(:call) 106 | Payola::Worker::BaseWorker.new.perform('Payola::SomeTestService') 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola/subscription_form_twostep.js: -------------------------------------------------------------------------------- 1 | var PayolaSubscriptionForm = { 2 | initialize: function() { 3 | $(document).on('submit', '.payola-subscription-form', function() { 4 | return PayolaSubscriptionForm.handleSubmit($(this)); 5 | }); 6 | }, 7 | 8 | handleSubmit: function(form) { 9 | $(':submit').prop('disabled', true); 10 | $('.payola-spinner').show(); 11 | Stripe.card.createToken(form, function(status, response) { 12 | PayolaSubscriptionForm.stripeResponseHandler(form, status, response); 13 | }); 14 | return false; 15 | }, 16 | 17 | stripeResponseHandler: function(form, status, response) { 18 | if (response.error) { 19 | PayolaSubscriptionForm.showError(form, response.error.message); 20 | } else { 21 | var email = form.find("[data-payola='email']").val(); 22 | var coupon = form.find("[data-payola='coupon']").val(); 23 | var quantity = form.find("[data-payola='quantity']").val(); 24 | 25 | var base_path = form.data('payola-base-path'); 26 | var plan_type = form.data('payola-plan-type'); 27 | var plan_id = form.data('payola-plan-id'); 28 | 29 | var data_form = $('
'); 30 | data_form.append($('').val(response.id)); 31 | data_form.append($('').val(email)); 32 | data_form.append($('').val(coupon)); 33 | data_form.append($('').val(quantity)); 34 | data_form.append(PayolaSubscriptionForm.authenticityTokenInput()); 35 | $.ajax({ 36 | type: "POST", 37 | url: base_path + "/subscribe/" + plan_type + "/" + plan_id, 38 | data: data_form.serialize(), 39 | success: function(data) { PayolaSubscriptionForm.poll(form, 60, data.guid, base_path); }, 40 | error: function(data) { PayolaSubscriptionForm.showError(form, data.responseJSON.error); } 41 | }); 42 | } 43 | }, 44 | 45 | poll: function(form, num_retries_left, guid, base_path) { 46 | if (num_retries_left === 0) { 47 | PayolaSubscriptionForm.showError(form, "This seems to be taking too long. Please contact support and give them transaction ID: " + guid); 48 | } 49 | var handler = function(data) { 50 | if (data.status === "active") { 51 | form.append($('').val(guid)); 52 | form.append(PayolaSubscriptionForm.authenticityTokenInput()); 53 | form.get(0).submit(); 54 | } else if (data.status === "errored") { 55 | PayolaSubscriptionForm.showError(form, data.error); 56 | } else { 57 | setTimeout(function() { PayolaSubscriptionForm.poll(form, num_retries_left - 1, guid, base_path); }, 500); 58 | } 59 | }; 60 | 61 | $.ajax({ 62 | type: 'GET', 63 | url: base_path + '/subscription_status/' + guid, 64 | success: handler, 65 | error: handler 66 | }); 67 | }, 68 | 69 | showError: function(form, message) { 70 | $('.payola-spinner').hide(); 71 | $(':submit').prop('disabled', false); 72 | var error_selector = form.data('payola-error-selector'); 73 | if (error_selector) { 74 | $(error_selector).text(message); 75 | $(error_selector).show(); 76 | } else { 77 | form.find('.payola-payment-error').text(message); 78 | form.find('.payola-payment-error').show(); 79 | } 80 | }, 81 | 82 | authenticityTokenInput: function() { 83 | return $('').val($('meta[name="csrf-token"]').attr("content")); 84 | } 85 | }; 86 | 87 | $(function() { PayolaSubscriptionForm.initialize() } ); 88 | -------------------------------------------------------------------------------- /app/assets/javascripts/payola/subscription_form_onestep.js: -------------------------------------------------------------------------------- 1 | var PayolaOnestepSubscriptionForm = { 2 | initialize: function() { 3 | $(document).on('submit', '.payola-onestep-subscription-form', function() { 4 | return PayolaOnestepSubscriptionForm.handleSubmit($(this)); 5 | }); 6 | }, 7 | 8 | handleSubmit: function(form) { 9 | $(':submit').prop('disabled', true); 10 | $('.payola-spinner').show(); 11 | Stripe.card.createToken(form, function(status, response) { 12 | PayolaOnestepSubscriptionForm.stripeResponseHandler(form, status, response); 13 | }); 14 | return false; 15 | }, 16 | 17 | stripeResponseHandler: function(form, status, response) { 18 | if (response.error) { 19 | PayolaOnestepSubscriptionForm.showError(form, response.error.message); 20 | } else { 21 | var email = form.find("[data-payola='email']").val(); 22 | var coupon = form.find("[data-payola='coupon']").val(); 23 | var quantity = form.find("[data-payola='quantity']").val(); 24 | 25 | var base_path = form.data('payola-base-path'); 26 | var plan_type = form.data('payola-plan-type'); 27 | var plan_id = form.data('payola-plan-id'); 28 | 29 | var action = $(form).attr('action'); 30 | 31 | form.append($('').val(plan_type)); 32 | form.append($('').val(plan_id)); 33 | form.append($('').val(response.id)); 34 | form.append($('').val(email)); 35 | form.append($('').val(coupon)); 36 | form.append($('').val(quantity)); 37 | form.append(PayolaOnestepSubscriptionForm.authenticityTokenInput()); 38 | $.ajax({ 39 | type: "POST", 40 | url: action, 41 | data: form.serialize(), 42 | success: function(data) { PayolaOnestepSubscriptionForm.poll(form, 60, data.guid, base_path); }, 43 | error: function(data) { PayolaOnestepSubscriptionForm.showError(form, data.responseJSON.error); } 44 | }); 45 | } 46 | }, 47 | 48 | poll: function(form, num_retries_left, guid, base_path) { 49 | if (num_retries_left === 0) { 50 | PayolaOnestepSubscriptionForm.showError(form, "This seems to be taking too long. Please contact support and give them transaction ID: " + guid); 51 | } 52 | var handler = function(data) { 53 | if (data.status === "active") { 54 | window.location = base_path + '/confirm_subscription/' + guid; 55 | } else if (data.status === "errored") { 56 | PayolaOnestepSubscriptionForm.showError(form, data.error); 57 | } else { 58 | setTimeout(function() { PayolaOnestepSubscriptionForm.poll(form, num_retries_left - 1, guid, base_path); }, 500); 59 | } 60 | }; 61 | 62 | $.ajax({ 63 | type: 'GET', 64 | url: base_path + '/subscription_status/' + guid, 65 | success: handler, 66 | error: handler 67 | }); 68 | }, 69 | 70 | showError: function(form, message) { 71 | $('.payola-spinner').hide(); 72 | $(':submit').prop('disabled', false); 73 | var error_selector = form.data('payola-error-selector'); 74 | if (error_selector) { 75 | $(error_selector).text(message); 76 | $(error_selector).show(); 77 | } else { 78 | form.find('.payola-payment-error').text(message); 79 | form.find('.payola-payment-error').show(); 80 | } 81 | }, 82 | 83 | authenticityTokenInput: function() { 84 | return $('').val($('meta[name="csrf-token"]').attr("content")); 85 | } 86 | }; 87 | 88 | $(function() { PayolaOnestepSubscriptionForm.initialize() } ); 89 | -------------------------------------------------------------------------------- /spec/payola_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe "#configure" do 5 | it "should pass the class back to the given block" do 6 | Payola.configure do |payola| 7 | expect(payola).to eq Payola 8 | end 9 | end 10 | end 11 | 12 | describe "keys" do 13 | it "should set publishable key from env" do 14 | ENV['STRIPE_PUBLISHABLE_KEY'] = 'some_key' 15 | Payola.reset! 16 | expect(Payola.publishable_key).to eq 'some_key' 17 | end 18 | 19 | it "should set secret key from env" do 20 | ENV['STRIPE_SECRET_KEY'] = 'some_secret' 21 | Payola.reset! 22 | expect(Payola.secret_key).to eq 'some_secret' 23 | end 24 | end 25 | 26 | describe "instrumentation" do 27 | it "should pass subscribe to StripeEvent" do 28 | StripeEvent.should_receive(:subscribe) 29 | Payola.subscribe('foo', 'blah') 30 | end 31 | it "should pass instrument to StripeEvent.backend" do 32 | ActiveSupport::Notifications.should_receive(:instrument) 33 | Payola.instrument('foo', 'blah') 34 | end 35 | it "should pass all to StripeEvent" do 36 | StripeEvent.should_receive(:all) 37 | Payola.all('blah') 38 | end 39 | end 40 | 41 | describe "#queue" do 42 | before do 43 | Payola.reset! 44 | 45 | Payola::Worker.registry ||= {} 46 | Payola::Worker.registry[:fake] = FakeWorker 47 | end 48 | 49 | describe "with symbol" do 50 | it "should find the correct background worker" do 51 | FakeWorker.should_receive(:call) 52 | 53 | Payola.background_worker = :fake 54 | Payola.queue!('blah') 55 | end 56 | 57 | it "should not find a background worker for an unknown symbol" do 58 | Payola.background_worker = :another_fake 59 | expect { Payola.queue!('blah') }.to raise_error(RuntimeError) 60 | end 61 | end 62 | 63 | describe "with callable" do 64 | it "should call the callable" do 65 | foo = nil 66 | 67 | Payola.background_worker = lambda do |sale| 68 | foo = sale 69 | end 70 | 71 | Payola.queue!('blah') 72 | 73 | expect(foo).to eq 'blah' 74 | end 75 | end 76 | 77 | describe "with nothing" do 78 | it "should call autofind" do 79 | FakeWorker.should_receive(:call).and_return(:true) 80 | Payola::Worker.should_receive(:autofind).and_return(FakeWorker) 81 | Payola.queue!('blah') 82 | end 83 | end 84 | end 85 | 86 | describe "#send_mail" do 87 | before do 88 | Payola.reset! 89 | 90 | Payola::Worker.registry ||= {} 91 | Payola::Worker.registry[:fake] = FakeWorker 92 | Payola.background_worker = :fake 93 | end 94 | 95 | it "should queue the SendMail service" do 96 | class FakeMailer < ActionMailer::Base 97 | def receipt(first, second) 98 | end 99 | end 100 | 101 | FakeWorker.should_receive(:call).with(Payola::SendMail, 'Payola::FakeMailer', 'receipt', 1, 2) 102 | Payola.send_mail(FakeMailer, :receipt, 1, 2) 103 | end 104 | end 105 | 106 | describe '#auto_emails' do 107 | before do 108 | Payola.reset! 109 | end 110 | 111 | it "should set up listeners for auto emails" do 112 | Payola.should_receive(:subscribe).with('payola.sale.finished').at_least(2) 113 | Payola.send_email_for :receipt, :admin_receipt 114 | end 115 | end 116 | 117 | describe "#secret_key_retriever" do 118 | it "should get called" do 119 | Payola.secret_key_retriever = lambda { |sale| 'foo' } 120 | expect(Payola.secret_key_for_sale('blah')).to eq 'foo' 121 | end 122 | end 123 | 124 | describe "#publishable_key_retriever" do 125 | it "should get called" do 126 | Payola.publishable_key_retriever = lambda { |sale| 'foo' } 127 | expect(Payola.publishable_key_for_sale('blah')).to eq 'foo' 128 | end 129 | end 130 | 131 | describe '#additional_charge_attributes' do 132 | it "should return a hash" do 133 | sale = double 134 | customer = double 135 | expect(Payola.additional_charge_attributes.call(sale, customer)).to eq({}) 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/payola.rb: -------------------------------------------------------------------------------- 1 | require "payola/engine" 2 | require "payola/worker" 3 | require 'stripe_event' 4 | require 'jquery-rails' 5 | 6 | module Payola 7 | 8 | DEFAULT_EMAILS = { 9 | receipt: [ 'payola.sale.finished', 'Payola::ReceiptMailer', :receipt ], 10 | refund: [ 'charge.refunded', 'Payola::ReceiptMailer', :refund ], 11 | admin_receipt: [ 'payola.sale.finished', 'Payola::AdminMailer', :receipt ], 12 | admin_dispute: [ 'dispute.created', 'Payola::AdminMailer', :dispute ], 13 | admin_refund: [ 'payola.sale.refunded', 'Payola::AdminMailer', :refund ], 14 | admin_failure: [ 'payola.sale.failed', 'Payola::AdminMailer', :failure ], 15 | } 16 | 17 | class << self 18 | attr_accessor :publishable_key, 19 | :publishable_key_retriever, 20 | :secret_key, 21 | :secret_key_retriever, 22 | :background_worker, 23 | :event_filter, 24 | :support_email, 25 | :sellables, 26 | :subscribables, 27 | :charge_verifier, 28 | :default_currency, 29 | :additional_charge_attributes, 30 | :guid_generator, 31 | :pdf_receipt 32 | 33 | def configure(&block) 34 | raise ArgumentError, "must provide a block" unless block_given? 35 | block.arity.zero? ? instance_eval(&block) : yield(self) 36 | end 37 | 38 | def secret_key_for_sale(sale) 39 | return secret_key_retriever.call(sale).to_s 40 | end 41 | 42 | def publishable_key_for_sale(sale) 43 | return publishable_key_retriever.call(sale).to_s 44 | end 45 | 46 | def subscribe(name, callable = Proc.new) 47 | StripeEvent.subscribe(name, callable) 48 | end 49 | 50 | def instrument(name, object) 51 | StripeEvent.backend.instrument(StripeEvent.namespace.call(name), object) 52 | end 53 | 54 | def all(callable = Proc.new) 55 | StripeEvent.all(callable) 56 | end 57 | 58 | def queue!(klass, *args) 59 | if background_worker.is_a? Symbol 60 | Payola::Worker.find(background_worker).call(klass, *args) 61 | elsif background_worker.respond_to?(:call) 62 | background_worker.call(klass, *args) 63 | else 64 | Payola::Worker.autofind.call(klass, *args) 65 | end 66 | end 67 | 68 | def send_mail(mailer, method, *args) 69 | Payola.queue!(Payola::SendMail, mailer.to_s, method.to_s, *args) 70 | end 71 | 72 | def reset! 73 | StripeEvent.event_retriever = Retriever 74 | 75 | self.background_worker = nil 76 | self.event_filter = lambda { |event| event } 77 | self.charge_verifier = lambda { |event| true } 78 | self.publishable_key = EnvWrapper.new('STRIPE_PUBLISHABLE_KEY') 79 | self.secret_key = EnvWrapper.new('STRIPE_SECRET_KEY') 80 | self.secret_key_retriever = lambda { |sale| Payola.secret_key } 81 | self.publishable_key_retriever = lambda { |sale| Payola.publishable_key } 82 | self.support_email = 'sales@example.com' 83 | self.default_currency = 'usd' 84 | self.sellables = {} 85 | self.subscribables = {} 86 | self.additional_charge_attributes = lambda { |sale, customer| { } } 87 | self.pdf_receipt = false 88 | self.guid_generator = lambda { SecureRandom.random_number(1_000_000_000).to_s(32) } 89 | end 90 | 91 | def register_sellable(klass) 92 | sellables[klass.product_class] = klass 93 | end 94 | 95 | def register_subscribable(klass) 96 | subscribables[klass.plan_class] = klass 97 | end 98 | 99 | def send_email_for(*emails) 100 | emails.each do |email| 101 | spec = DEFAULT_EMAILS[email].dup 102 | if spec 103 | Payola.subscribe(spec.shift) do |sale| 104 | if sale.is_a?(Stripe::Event) 105 | sale = Payola::Sale.find_by!(stripe_id: sale.data.object.id) 106 | end 107 | 108 | Payola.send_mail(*(spec + [sale.guid])) 109 | end 110 | end 111 | end 112 | end 113 | end 114 | 115 | class Retriever 116 | def self.call(params) 117 | return nil if StripeWebhook.exists?(stripe_id: params[:id]) 118 | StripeWebhook.create!(stripe_id: params[:id]) 119 | event = Stripe::Event.retrieve(params[:id], Payola.secret_key) 120 | Payola.event_filter.call(event) 121 | end 122 | end 123 | 124 | class EnvWrapper 125 | def initialize(key) 126 | @key = key 127 | end 128 | 129 | def to_s 130 | ENV[@key] 131 | end 132 | 133 | def ==(other) 134 | to_s == other.to_s 135 | end 136 | end 137 | 138 | self.reset! 139 | end 140 | -------------------------------------------------------------------------------- /app/models/payola/subscription.rb: -------------------------------------------------------------------------------- 1 | require 'aasm' 2 | 3 | module Payola 4 | class Subscription < ActiveRecord::Base 5 | include Payola::GuidBehavior 6 | 7 | has_paper_trail if respond_to? :has_paper_trail 8 | 9 | validates_presence_of :email 10 | validates_presence_of :plan_id 11 | validates_presence_of :plan_type 12 | validates_presence_of :stripe_token 13 | validates_presence_of :currency 14 | 15 | belongs_to :plan, polymorphic: true 16 | belongs_to :owner, polymorphic: true 17 | belongs_to :affiliate 18 | 19 | has_many :sales, class_name: 'Payola::Sale' 20 | 21 | include AASM 22 | 23 | attr_accessor :old_plan, :old_quantity 24 | 25 | aasm column: 'state', skip_validation_on_save: true do 26 | state :pending, initial: true 27 | state :processing 28 | state :active 29 | state :canceled 30 | state :errored 31 | 32 | event :process, after: :start_subscription do 33 | transitions from: :pending, to: :processing 34 | end 35 | 36 | event :activate, after: :instrument_activate do 37 | transitions from: :processing, to: :active 38 | end 39 | 40 | event :cancel, after: :instrument_canceled do 41 | transitions from: :active, to: :canceled 42 | end 43 | 44 | event :fail, after: :instrument_fail do 45 | transitions from: [:pending, :processing], to: :errored 46 | end 47 | 48 | event :refund, after: :instrument_refund do 49 | transitions from: :finished, to: :refunded 50 | end 51 | end 52 | 53 | def name 54 | self.plan.name 55 | end 56 | 57 | def price 58 | self.plan.amount 59 | end 60 | 61 | def redirect_path(sale) 62 | self.plan.redirect_path(self) 63 | end 64 | 65 | def verifier 66 | @verifier ||= ActiveSupport::MessageVerifier.new(Payola.secret_key_for_sale(self), digest: 'SHA256') 67 | end 68 | 69 | def verify_charge 70 | begin 71 | self.verify_charge! 72 | rescue RuntimeError => e 73 | self.error = e.message 74 | self.fail! 75 | end 76 | end 77 | 78 | def verify_charge! 79 | if Payola.charge_verifier.arity > 1 80 | Payola.charge_verifier.call(self, custom_fields) 81 | else 82 | Payola.charge_verifier.call(self) 83 | end 84 | end 85 | 86 | def custom_fields 87 | if self.signed_custom_fields.present? 88 | verifier.verify(self.signed_custom_fields) 89 | else 90 | nil 91 | end 92 | end 93 | 94 | def sync_with!(stripe_sub) 95 | self.current_period_start = Time.at(stripe_sub.current_period_start) 96 | self.current_period_end = Time.at(stripe_sub.current_period_end) 97 | self.ended_at = Time.at(stripe_sub.ended_at) if stripe_sub.ended_at 98 | self.trial_start = Time.at(stripe_sub.trial_start) if stripe_sub.trial_start 99 | self.trial_end = Time.at(stripe_sub.trial_end) if stripe_sub.trial_end 100 | self.canceled_at = Time.at(stripe_sub.canceled_at) if stripe_sub.canceled_at 101 | self.quantity = stripe_sub.quantity 102 | self.stripe_status = stripe_sub.status 103 | self.cancel_at_period_end = stripe_sub.cancel_at_period_end 104 | 105 | self.save! 106 | self 107 | end 108 | 109 | def to_param 110 | guid 111 | end 112 | 113 | def instrument_plan_changed(old_plan) 114 | self.old_plan = old_plan 115 | Payola.instrument(instrument_key('plan_changed'), self) 116 | Payola.instrument(instrument_key('plan_changed', false), self) 117 | end 118 | 119 | def instrument_quantity_changed(old_quantity) 120 | self.old_quantity = old_quantity 121 | Payola.instrument(instrument_key('quantity_changed'), self) 122 | Payola.instrument(instrument_key('quantity_changed', false), self) 123 | end 124 | 125 | def redirector 126 | plan 127 | end 128 | 129 | private 130 | 131 | def start_subscription 132 | Payola::StartSubscription.call(self) 133 | end 134 | 135 | def instrument_activate 136 | Payola.instrument(instrument_key('active'), self) 137 | Payola.instrument(instrument_key('active', false), self) 138 | end 139 | 140 | def instrument_canceled 141 | Payola.instrument(instrument_key('canceled'), self) 142 | Payola.instrument(instrument_key('canceled', false), self) 143 | end 144 | 145 | def instrument_fail 146 | Payola.instrument(instrument_key('failed'), self) 147 | Payola.instrument(instrument_key('failed', false), self) 148 | end 149 | 150 | def instrument_refund 151 | Payola.instrument(instrument_key('refunded'), self) 152 | Payola.instrument(instrument_key('refunded', false), self) 153 | end 154 | 155 | def instrument_key(instrument_type, include_class=true) 156 | if include_class 157 | "payola.#{plan_type}.subscription.#{instrument_type}" 158 | else 159 | "payola.subscription.#{instrument_type}" 160 | end 161 | end 162 | 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20141213205847) do 15 | 16 | create_table "owners", force: :cascade do |t| 17 | t.datetime "created_at" 18 | t.datetime "updated_at" 19 | end 20 | 21 | create_table "payola_affiliates", force: :cascade do |t| 22 | t.string "code" 23 | t.string "email" 24 | t.integer "percent" 25 | t.datetime "created_at" 26 | t.datetime "updated_at" 27 | end 28 | 29 | create_table "payola_coupons", force: :cascade do |t| 30 | t.string "code" 31 | t.integer "percent_off" 32 | t.datetime "created_at" 33 | t.datetime "updated_at" 34 | t.boolean "active", default: true 35 | end 36 | 37 | create_table "payola_sales", force: :cascade do |t| 38 | t.string "email" 39 | t.string "guid" 40 | t.integer "product_id" 41 | t.string "product_type" 42 | t.datetime "created_at" 43 | t.datetime "updated_at" 44 | t.string "state" 45 | t.string "stripe_id" 46 | t.string "stripe_token" 47 | t.string "card_last4" 48 | t.date "card_expiration" 49 | t.string "card_type" 50 | t.text "error" 51 | t.integer "amount" 52 | t.integer "fee_amount" 53 | t.integer "coupon_id" 54 | t.boolean "opt_in" 55 | t.integer "download_count" 56 | t.integer "affiliate_id" 57 | t.text "customer_address" 58 | t.text "business_address" 59 | t.string "stripe_customer_id" 60 | t.string "currency" 61 | t.text "signed_custom_fields" 62 | t.integer "owner_id" 63 | t.string "owner_type" 64 | end 65 | 66 | add_index "payola_sales", ["coupon_id"], name: "index_payola_sales_on_coupon_id" 67 | add_index "payola_sales", ["email"], name: "index_payola_sales_on_email" 68 | add_index "payola_sales", ["guid"], name: "index_payola_sales_on_guid" 69 | add_index "payola_sales", ["owner_id", "owner_type"], name: "index_payola_sales_on_owner_id_and_owner_type" 70 | add_index "payola_sales", ["product_id", "product_type"], name: "index_payola_sales_on_product" 71 | add_index "payola_sales", ["stripe_customer_id"], name: "index_payola_sales_on_stripe_customer_id" 72 | 73 | create_table "payola_stripe_webhooks", force: :cascade do |t| 74 | t.string "stripe_id" 75 | t.datetime "created_at" 76 | t.datetime "updated_at" 77 | end 78 | 79 | create_table "payola_subscriptions", force: :cascade do |t| 80 | t.string "plan_type" 81 | t.integer "plan_id" 82 | t.datetime "start" 83 | t.string "status" 84 | t.string "owner_type" 85 | t.integer "owner_id" 86 | t.string "stripe_customer_id" 87 | t.boolean "cancel_at_period_end" 88 | t.datetime "current_period_start" 89 | t.datetime "current_period_end" 90 | t.datetime "ended_at" 91 | t.datetime "trial_start" 92 | t.datetime "trial_end" 93 | t.datetime "canceled_at" 94 | t.integer "quantity" 95 | t.string "stripe_id" 96 | t.string "stripe_token" 97 | t.string "card_last4" 98 | t.date "card_expiration" 99 | t.string "card_type" 100 | t.text "error" 101 | t.string "state" 102 | t.string "email" 103 | t.datetime "created_at" 104 | t.datetime "updated_at" 105 | t.string "currency" 106 | t.integer "amount" 107 | t.string "guid" 108 | t.string "stripe_status" 109 | t.integer "affiliate_id" 110 | t.string "coupon" 111 | t.text "signed_custom_fields" 112 | t.text "customer_address" 113 | t.text "business_address" 114 | t.integer "setup_fee" 115 | end 116 | 117 | add_index "payola_subscriptions", ["guid"], name: "index_payola_subscriptions_on_guid" 118 | 119 | create_table "products", force: :cascade do |t| 120 | t.string "name" 121 | t.string "permalink" 122 | t.integer "price" 123 | t.datetime "created_at" 124 | t.datetime "updated_at" 125 | end 126 | 127 | create_table "subscription_plan_without_interval_counts", force: :cascade do |t| 128 | t.string "name" 129 | t.string "stripe_id" 130 | t.integer "amount" 131 | t.string "interval" 132 | t.datetime "created_at" 133 | t.datetime "updated_at" 134 | end 135 | 136 | create_table "subscription_plans", force: :cascade do |t| 137 | t.integer "amount" 138 | t.string "interval" 139 | t.integer "interval_count" 140 | t.string "name" 141 | t.string "stripe_id" 142 | t.integer "trial_period_days" 143 | t.datetime "created_at" 144 | t.datetime "updated_at" 145 | end 146 | 147 | create_table "users", force: :cascade do |t| 148 | t.datetime "created_at" 149 | t.datetime "updated_at" 150 | end 151 | 152 | end 153 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/de.yml: -------------------------------------------------------------------------------- 1 | # source: https://raw.githubusercontent.com/svenfuchs/rails-i18n/master/rails/locale/de.yml 2 | de: 3 | date: 4 | abbr_day_names: 5 | - So 6 | - Mo 7 | - Di 8 | - Mi 9 | - Do 10 | - Fr 11 | - Sa 12 | abbr_month_names: 13 | - 14 | - Jan 15 | - Feb 16 | - Mär 17 | - Apr 18 | - Mai 19 | - Jun 20 | - Jul 21 | - Aug 22 | - Sep 23 | - Okt 24 | - Nov 25 | - Dez 26 | day_names: 27 | - Sonntag 28 | - Montag 29 | - Dienstag 30 | - Mittwoch 31 | - Donnerstag 32 | - Freitag 33 | - Samstag 34 | formats: 35 | default: ! '%d.%m.%Y' 36 | long: ! '%e. %B %Y' 37 | short: ! '%e. %b' 38 | month_names: 39 | - 40 | - Januar 41 | - Februar 42 | - März 43 | - April 44 | - Mai 45 | - Juni 46 | - Juli 47 | - August 48 | - September 49 | - Oktober 50 | - November 51 | - Dezember 52 | order: 53 | - :day 54 | - :month 55 | - :year 56 | datetime: 57 | distance_in_words: 58 | about_x_hours: 59 | one: etwa eine Stunde 60 | other: etwa %{count} Stunden 61 | about_x_months: 62 | one: etwa ein Monat 63 | other: etwa %{count} Monate 64 | about_x_years: 65 | one: etwa ein Jahr 66 | other: etwa %{count} Jahre 67 | almost_x_years: 68 | one: fast ein Jahr 69 | other: fast %{count} Jahre 70 | half_a_minute: eine halbe Minute 71 | less_than_x_minutes: 72 | one: weniger als eine Minute 73 | other: weniger als %{count} Minuten 74 | less_than_x_seconds: 75 | one: weniger als eine Sekunde 76 | other: weniger als %{count} Sekunden 77 | over_x_years: 78 | one: mehr als ein Jahr 79 | other: mehr als %{count} Jahre 80 | x_days: 81 | one: ein Tag 82 | other: ! '%{count} Tage' 83 | x_minutes: 84 | one: eine Minute 85 | other: ! '%{count} Minuten' 86 | x_months: 87 | one: ein Monat 88 | other: ! '%{count} Monate' 89 | x_seconds: 90 | one: eine Sekunde 91 | other: ! '%{count} Sekunden' 92 | prompts: 93 | day: Tag 94 | hour: Stunden 95 | minute: Minuten 96 | month: Monat 97 | second: Sekunden 98 | year: Jahr 99 | errors: 100 | format: ! '%{attribute} %{message}' 101 | messages: 102 | accepted: muss akzeptiert werden 103 | blank: muss ausgefüllt werden 104 | present: darf nicht ausgefüllt werden 105 | confirmation: stimmt nicht mit %{attribute} überein 106 | empty: muss ausgefüllt werden 107 | equal_to: muss genau %{count} sein 108 | even: muss gerade sein 109 | exclusion: ist nicht verfügbar 110 | greater_than: muss größer als %{count} sein 111 | greater_than_or_equal_to: muss größer oder gleich %{count} sein 112 | inclusion: ist kein gültiger Wert 113 | invalid: ist nicht gültig 114 | less_than: muss kleiner als %{count} sein 115 | less_than_or_equal_to: muss kleiner oder gleich %{count} sein 116 | not_a_number: ist keine Zahl 117 | not_an_integer: muss ganzzahlig sein 118 | odd: muss ungerade sein 119 | record_invalid: ! 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' 120 | restrict_dependent_destroy: 121 | one: ! 'Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz existiert.' 122 | many: ! 'Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.' 123 | taken: ist bereits vergeben 124 | too_long: ist zu lang (mehr als %{count} Zeichen) 125 | too_short: ist zu kurz (weniger als %{count} Zeichen) 126 | wrong_length: hat die falsche Länge (muss genau %{count} Zeichen haben) 127 | other_than: darf nicht gleich %{count} sein 128 | template: 129 | body: ! 'Bitte überprüfen Sie die folgenden Felder:' 130 | header: 131 | one: ! 'Konnte %{model} nicht speichern: ein Fehler.' 132 | other: ! 'Konnte %{model} nicht speichern: %{count} Fehler.' 133 | helpers: 134 | select: 135 | prompt: Bitte wählen 136 | submit: 137 | create: ! '%{model} erstellen' 138 | submit: ! '%{model} speichern' 139 | update: ! '%{model} aktualisieren' 140 | number: 141 | currency: 142 | format: 143 | delimiter: . 144 | format: ! '%n %u' 145 | precision: 2 146 | separator: ! ',' 147 | significant: false 148 | strip_insignificant_zeros: false 149 | unit: € 150 | format: 151 | delimiter: . 152 | precision: 2 153 | separator: ! ',' 154 | significant: false 155 | strip_insignificant_zeros: false 156 | human: 157 | decimal_units: 158 | format: ! '%n %u' 159 | units: 160 | billion: 161 | one: Milliarde 162 | other: Milliarden 163 | million: Millionen 164 | quadrillion: 165 | one: Billiarde 166 | other: Billiarden 167 | thousand: Tausend 168 | trillion: Billionen 169 | unit: '' 170 | format: 171 | delimiter: '' 172 | precision: 1 173 | significant: true 174 | strip_insignificant_zeros: true 175 | storage_units: 176 | format: ! '%n %u' 177 | units: 178 | byte: 179 | one: Byte 180 | other: Bytes 181 | gb: GB 182 | kb: KB 183 | mb: MB 184 | tb: TB 185 | percentage: 186 | format: 187 | delimiter: '' 188 | format: "%n%" 189 | precision: 190 | format: 191 | delimiter: '' 192 | support: 193 | array: 194 | last_word_connector: ! ' und ' 195 | two_words_connector: ! ' und ' 196 | words_connector: ! ', ' 197 | time: 198 | am: vormittags 199 | formats: 200 | default: ! '%A, %d. %B %Y, %H:%M Uhr' 201 | long: ! '%A, %d. %B %Y, %H:%M Uhr' 202 | short: ! '%d. %B, %H:%M Uhr' 203 | pm: nachmittags 204 | -------------------------------------------------------------------------------- /app/views/payola/transactions/_form.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= form_tag payola.buy_path(product_class: sale.product.product_class, permalink: permalink), :class => '', :id => 'payment-form' do %> 3 | <% if coupon %> 4 | 5 | <% end %> 6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 | 22 | 23 | / 24 | 25 |
26 |
27 |
28 |
<%= price %>
29 |
30 | 31 |
32 |
33 |
34 | <% end %> 35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 | <% sale.errors.full_messages.each do |msg| %> 43 | <%= msg %> 44 | <% end %> 45 |
46 |
47 |
48 | 49 | 142 | -------------------------------------------------------------------------------- /spec/controllers/payola/subscriptions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Payola 4 | describe SubscriptionsController do 5 | routes { Payola::Engine.routes } 6 | 7 | before do 8 | @plan = create(:subscription_plan) 9 | Payola.register_subscribable(@plan.class) 10 | end 11 | 12 | describe '#create' do 13 | it "should pass args to CreateSubscription" do 14 | subscription = double 15 | subscription.should_receive(:save).and_return(true) 16 | subscription.should_receive(:guid).at_least(1).times.and_return(1) 17 | subscription.should_receive(:error).and_return(nil) 18 | errors = double 19 | errors.should_receive(:full_messages).and_return([]) 20 | subscription.should_receive(:errors).and_return(errors) 21 | subscription.should_receive(:state).and_return('pending') 22 | 23 | CreateSubscription.should_receive(:call).with( 24 | 'plan_class' => 'subscription_plan', 25 | 'plan_id' => @plan.id.to_s, 26 | 'controller' => 'payola/subscriptions', 27 | 'action' => 'create', 28 | 'plan' => @plan, 29 | 'coupon' => nil, 30 | 'quantity' => 1, 31 | 'affiliate' => nil 32 | ).and_return(subscription) 33 | 34 | post :create, plan_class: @plan.plan_class, plan_id: @plan.id 35 | 36 | expect(response.status).to eq 200 37 | parsed_body = JSON.load(response.body) 38 | expect(parsed_body['guid']).to eq 1 39 | end 40 | 41 | describe "with an error" do 42 | it "should return an error in json" do 43 | subscription = double 44 | subscription.should_receive(:save).and_return(false) 45 | error = double 46 | error.should_receive(:full_messages).and_return(['done did broke']) 47 | subscription.should_receive(:errors).and_return(error) 48 | subscription.should_receive(:state).and_return('errored') 49 | subscription.should_receive(:error).and_return('') 50 | subscription.should_receive(:guid).and_return('blah') 51 | 52 | 53 | CreateSubscription.should_receive(:call).and_return(subscription) 54 | Payola.should_not_receive(:queue!) 55 | 56 | post :create, plan_class: @plan.plan_class, plan_id: @plan.id 57 | 58 | expect(response.status).to eq 400 59 | parsed_body = JSON.load(response.body) 60 | expect(parsed_body['error']).to eq 'done did broke' 61 | end 62 | end 63 | end 64 | 65 | describe '#status' do 66 | it "should return 404 if it can't find the subscription" do 67 | get :status, guid: 'doesnotexist' 68 | expect(response.status).to eq 404 69 | end 70 | it "should return json with properties" do 71 | subscription = create(:subscription) 72 | get :status, guid: subscription.guid 73 | 74 | expect(response.status).to eq 200 75 | 76 | parsed_body = JSON.load(response.body) 77 | 78 | expect(parsed_body['guid']).to eq subscription.guid 79 | expect(parsed_body['status']).to eq subscription.state 80 | expect(parsed_body['error']).to be_nil 81 | end 82 | end 83 | 84 | describe '#show' do 85 | it "should redirect to the product's redirect path" do 86 | plan = create(:subscription_plan) 87 | subscription = create(:subscription, :plan => plan) 88 | get :show, guid: subscription.guid 89 | 90 | expect(response).to redirect_to '/' 91 | end 92 | end 93 | 94 | describe '#destroy' do 95 | before :each do 96 | @subscription = create(:subscription, :state => :active) 97 | end 98 | it "call Payola::CancelSubscription and redirect" do 99 | Payola::CancelSubscription.should_receive(:call) 100 | delete :destroy, guid: @subscription.guid 101 | # TODO : Figure out why this needs to be a hardcoded path. 102 | # Why doesn't subscription_path(@subscription) work? 103 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 104 | end 105 | 106 | it "should redirect with an error if it can't cancel the subscription" do 107 | expect(Payola::CancelSubscription).to_not receive(:call) 108 | expect_any_instance_of(::ApplicationController).to receive(:payola_can_modify_subscription?).and_return(false) 109 | 110 | delete :destroy, guid: @subscription.guid 111 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 112 | expect(request.flash[:alert]).to eq 'You cannot modify this subscription.' 113 | end 114 | end 115 | 116 | describe '#change_plan' do 117 | before :each do 118 | @subscription = create(:subscription, state: :active) 119 | @plan = create(:subscription_plan) 120 | end 121 | 122 | it "should call Payola::ChangeSubscriptionPlan and redirect" do 123 | expect(Payola::ChangeSubscriptionPlan).to receive(:call).with(@subscription, @plan) 124 | 125 | post :change_plan, guid: @subscription.guid, plan_class: @plan.plan_class, plan_id: @plan.id 126 | 127 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 128 | expect(request.flash[:notice]).to eq 'Subscription plan updated' 129 | end 130 | 131 | it "should redirect with an error if it can't update the subscription" do 132 | expect(Payola::ChangeSubscriptionPlan).to_not receive(:call) 133 | expect_any_instance_of(::ApplicationController).to receive(:payola_can_modify_subscription?).and_return(false) 134 | 135 | post :change_plan, guid: @subscription.guid, plan_class: @plan.plan_class, plan_id: @plan.id 136 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 137 | expect(request.flash[:alert]).to eq 'You cannot modify this subscription.' 138 | end 139 | end 140 | 141 | describe '#change_quantity' do 142 | before :each do 143 | @subscription = create(:subscription, state: :active) 144 | @plan = create(:subscription_plan) 145 | end 146 | 147 | it "should call Payola::ChangeSubscriptionQuantity and redirect" do 148 | expect(Payola::ChangeSubscriptionQuantity).to receive(:call).with(@subscription, 5) 149 | 150 | post :change_quantity, guid: @subscription.guid, quantity: 5 151 | 152 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 153 | expect(request.flash[:notice]).to eq 'Subscription quantity updated' 154 | end 155 | 156 | it "should redirect with an error if it can't update the subscription" do 157 | expect(Payola::ChangeSubscriptionQuantity).to_not receive(:call) 158 | expect_any_instance_of(::ApplicationController).to receive(:payola_can_modify_subscription?).and_return(false) 159 | 160 | post :change_quantity, guid: @subscription.guid, quantity: 5 161 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 162 | expect(request.flash[:alert]).to eq 'You cannot modify this subscription.' 163 | end 164 | end 165 | 166 | describe "#update_card" do 167 | before :each do 168 | @subscription = create(:subscription, state: :active) 169 | @plan = create(:subscription_plan) 170 | end 171 | 172 | it "should call UpdateCard and redirect" do 173 | expect(Payola::UpdateCard).to receive(:call).with(@subscription, 'tok_1234') 174 | 175 | post :update_card, guid: @subscription.guid, stripeToken: 'tok_1234' 176 | 177 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 178 | expect(request.flash[:notice]).to eq 'Card updated' 179 | end 180 | 181 | it "should redirect with an error" do 182 | expect(Payola::UpdateCard).to receive(:call).never 183 | expect_any_instance_of(::ApplicationController).to receive(:payola_can_modify_subscription?).and_return(false) 184 | 185 | post :update_card, guid: @subscription.guid, stripeToken: 'tok_1234' 186 | 187 | expect(response).to redirect_to "/subdir/payola/confirm_subscription/#{@subscription.guid}" 188 | expect(request.flash[:alert]).to eq 'You cannot modify this subscription.' 189 | end 190 | end 191 | 192 | end 193 | end 194 | --------------------------------------------------------------------------------