saas_payments 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +146 -0
  4. data/Rakefile +32 -0
  5. data/app/assets/config/saas_payments_manifest.js +2 -0
  6. data/app/assets/javascripts/saas_payments/application.js +15 -0
  7. data/app/assets/javascripts/saas_payments/elements.js +146 -0
  8. data/app/assets/stylesheets/saas_payments/application.css +15 -0
  9. data/app/assets/stylesheets/saas_payments/elements.css +31 -0
  10. data/app/controllers/concerns/saas_payments/subscription_concerns.rb +173 -0
  11. data/app/controllers/concerns/saas_payments/webhook_concerns.rb +22 -0
  12. data/app/controllers/saas_payments/application_controller.rb +17 -0
  13. data/app/controllers/saas_payments/webhook_controller.rb +10 -0
  14. data/app/helpers/saas_payments/application_helper.rb +4 -0
  15. data/app/jobs/saas_payments/application_job.rb +4 -0
  16. data/app/lib/saas_payments/products_service.rb +25 -0
  17. data/app/lib/saas_payments/webhook/customer_event.rb +29 -0
  18. data/app/lib/saas_payments/webhook/plan_event.rb +25 -0
  19. data/app/lib/saas_payments/webhook/product_event.rb +25 -0
  20. data/app/lib/saas_payments/webhook/session_event.rb +6 -0
  21. data/app/lib/saas_payments/webhook/subscription_event.rb +25 -0
  22. data/app/lib/saas_payments/webhook_service.rb +39 -0
  23. data/app/mailers/saas_payments/application_mailer.rb +6 -0
  24. data/app/models/concerns/saas_payments/stripe_model.rb +32 -0
  25. data/app/models/saas_payments/application_record.rb +5 -0
  26. data/app/models/saas_payments/customer.rb +42 -0
  27. data/app/models/saas_payments/plan.rb +28 -0
  28. data/app/models/saas_payments/product.rb +24 -0
  29. data/app/models/saas_payments/subscription.rb +31 -0
  30. data/app/views/saas_payments/_scripts.html.erb +5 -0
  31. data/app/views/saas_payments/_stripe_elements.html.erb +26 -0
  32. data/config/initializers/stripe.rb +0 -0
  33. data/config/routes.rb +4 -0
  34. data/db/migrate/20190818184232_create_saas_payments_subscriptions.rb +28 -0
  35. data/db/migrate/20190820040835_create_saas_payments_products.rb +19 -0
  36. data/db/migrate/20190820040959_create_saas_payments_plans.rb +25 -0
  37. data/db/migrate/20190823023237_create_saas_payments_customers.rb +19 -0
  38. data/lib/saas_payments.rb +15 -0
  39. data/lib/saas_payments/config.rb +9 -0
  40. data/lib/saas_payments/engine.rb +18 -0
  41. data/lib/saas_payments/errors.rb +3 -0
  42. data/lib/saas_payments/version.rb +3 -0
  43. data/lib/tasks/products.rake +6 -0
  44. data/lib/tasks/saas_payments_tasks.rake +4 -0
  45. metadata +184 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 399ad7181e74c3e135ffbf7c1990babb8d1aec389a453fe0b5b2414401bb6f9b
4
+ data.tar.gz: da5bc44556fd4496ae64bece2da30f42cbdba80f77d79278714dad01681075f2
5
+ SHA512:
6
+ metadata.gz: da830a93f1a7c9a757fa2a21c2068296b6d40198479ff802d8740a09ff0ee297582adbc94f50b127ee315ad3e3e00d5e08efb6818bba62753ff24642af20e4f5
7
+ data.tar.gz: 0765fa876818e55cd8b521199edf1adc58a77b96269bb54012a5ab1f74bebbab03555ac3f4571d98e02d8ad8b2479d82f79a9ad571157227944b79afd911facb
@@ -0,0 +1,20 @@
1
+ Copyright 2019 Cody
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,146 @@
1
+ # SaasPayments
2
+
3
+ SaasPayments is a thin wrapper around the Stripe API to manage the most common
4
+ SAAS subscription actions. This gem includes
5
+
6
+ - [ ] Subscription model to hold the state of a user's subscription
7
+ - [ ] Payment form template
8
+ - [ ] Webhook to handle subscription updates from Stripe
9
+ - [ ] Sign up, cancel, and change plan routes
10
+
11
+
12
+ This Gem also assumes you already have:
13
+
14
+ - A `User` model (If you don't, try [Devise](https://github.com/plataformatec/devise))
15
+
16
+ # Getting Started
17
+
18
+ ## Install
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ # Gemfile
23
+
24
+ gem 'saas_payments'
25
+ ```
26
+
27
+ And then run:
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ ## Migrations
33
+ ```bash
34
+ rake saas_payments:install:migrations
35
+ rails db:migrate
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ ```ruby
41
+ SaasPayments.configure do |config|
42
+ # Stripe API Keys
43
+ config.stripe_secret_key = "sk_...."
44
+ config.stripe_publishable_key = "pk_..."
45
+
46
+ # Path to return to after successful subscription changes
47
+ config.account_path = '/dashboard'
48
+ end
49
+ ```
50
+
51
+ ## Importing data from Stripe
52
+
53
+ ### Rake task to import Products/Plans
54
+ ```bash
55
+ rake products:sync
56
+ ```
57
+
58
+ ## Managing Subscriptions
59
+
60
+ Subscriptions are managed using a set of controller concerns. Subscriptions
61
+ can be created and modified by including the `SaasPayments::Subscriptions`
62
+ concern into your controller.
63
+
64
+ ### Creating subscriptions
65
+
66
+ Currently, only one subscription is supported per user. Should a user enter
67
+ their card information more than once, their current subscription will be
68
+ updated, rather than creating a second subscription.
69
+
70
+ ```ruby
71
+ class AccountsController < ApplicationController
72
+ # Include the subscriptions concern
73
+ include SaasPayments::Subscriptions
74
+
75
+ def create
76
+ sign_up_for_plan @current_user, @plan
77
+ end
78
+
79
+ end
80
+ ```
81
+
82
+ ### Cancellation
83
+
84
+ NOTE: Canceled subscriptions will be canceled at the end of the period.
85
+
86
+ ```ruby
87
+ # Host application
88
+ class AccountsController < ApplicationController
89
+ # Include the subscriptions concern
90
+ include SaasPayments::Subscriptions
91
+
92
+ def cancel
93
+ # Perform any logic you need
94
+
95
+ # Call the cancel_subscription method to perform the
96
+ # cancellation
97
+ cancel_at_period_end @current_user
98
+ end
99
+ end
100
+ ```
101
+
102
+ ### Changing Plans
103
+
104
+ The subscriptions concern also contains a method for changing plans.
105
+
106
+ ```ruby
107
+ # Host application
108
+ class AccountsController < ApplicationController
109
+ # Include the subscriptions concern
110
+ include SaasPayments::Subscriptions
111
+
112
+ def change_plan
113
+ # Perform any logic you need
114
+
115
+ # Call the change_plan method to change the plan a user is
116
+ # subscribed to.
117
+ change_plan @current_user, @plan
118
+ end
119
+ end
120
+ ```
121
+
122
+ ## Development
123
+
124
+ - Use `bin/rails`. For example, generators using this executable will create
125
+ models/controllers/etc that are namespaced to the saas_payments gem
126
+
127
+
128
+ ## Testing ([Rails Guide](https://guides.rubyonrails.org/engines.html#testing-an-engine))
129
+ ```bash
130
+ make test
131
+ ```
132
+
133
+ ## Contributing
134
+ Contribution directions go here.
135
+
136
+
137
+ ## License
138
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
139
+
140
+
141
+ ## TODO
142
+
143
+ - [ ] Per unit pricing
144
+ - [ ] Tiered pricing
145
+ - [ ] Metered pricing
146
+ - [ ] Product pricing
@@ -0,0 +1,32 @@
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 = 'SaasPayments'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ require 'bundler/gem_tasks'
23
+
24
+ require 'rake/testtask'
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << 'test'
28
+ t.pattern = 'test/**/*_test.rb'
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test
@@ -0,0 +1,2 @@
1
+ //= link_directory ../javascripts/saas_payments .js
2
+ //= link_directory ../stylesheets/saas_payments .css
@@ -0,0 +1,15 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // compiled file. JavaScript code in this file should be added after the last require_* statement.
9
+ //
10
+ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11
+ // about supported directives.
12
+ //
13
+ //= require rails-ujs
14
+ //= require activestorage
15
+ //= require_tree .
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ document.addEventListener('DOMContentLoaded', function() {
4
+
5
+ var Card = function(stripe) {
6
+ // Create an instance of Elements.
7
+ var elements = stripe.elements();
8
+
9
+ // Custom styling can be passed to options when creating an Element.
10
+ // (Note that this demo uses a wider set of styles than the guide below.)
11
+ var style = {
12
+ base: {
13
+ color: '#32325d',
14
+ fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
15
+ fontSmoothing: 'antialiased',
16
+ fontSize: '16px',
17
+ '::placeholder': {
18
+ color: '#aab7c4'
19
+ }
20
+ },
21
+ invalid: {
22
+ color: '#fa755a',
23
+ iconColor: '#fa755a'
24
+ }
25
+ };
26
+
27
+ // Create an instance of the card Element.
28
+ var card = elements.create('card', {style: style});
29
+
30
+ // Handle real-time validation errors from the card Element.
31
+ card.addEventListener('change', function(event) {
32
+ var displayError = document.getElementById('card-errors');
33
+ if (event.error) {
34
+ displayError.textContent = event.error.message;
35
+ } else {
36
+ displayError.textContent = '';
37
+ }
38
+ });
39
+
40
+ return card
41
+ }
42
+
43
+ var SPElements = function() {
44
+ // Retrieve the publishable key
45
+ var key = document.getElementById("publishable-key").dataset.publishable
46
+
47
+ // Create a Stripe client.
48
+ var stripe = Stripe(key);
49
+
50
+ var options = options || {}
51
+ var card = Card(stripe);
52
+ var forms = document.getElementsByClassName('payment-form')
53
+
54
+ var init = function() {
55
+ for (var i = 0; i < forms.length; i++) {
56
+ initForm(forms[i])
57
+ }
58
+ if (forms.length === 1) {
59
+ this.show(forms[0].id)
60
+ }
61
+ }
62
+
63
+ // Submit the form with the token ID.
64
+ var stripeTokenHandler = function(form, token) {
65
+ // Insert the token ID into the form so it gets submitted to the server
66
+ var hiddenInput = document.createElement('input');
67
+ hiddenInput.setAttribute('type', 'hidden');
68
+ hiddenInput.setAttribute('name', 'stripeToken');
69
+ hiddenInput.setAttribute('value', token.id);
70
+ form.appendChild(hiddenInput);
71
+
72
+ // Submit the form
73
+ form.submit();
74
+ }
75
+
76
+ var initForm = function(form) {
77
+ // Handle form submission.
78
+ form.addEventListener('submit', onSubmit.bind(this, form))
79
+ }
80
+
81
+ var onSubmit = function(form, event) {
82
+ if (event) {
83
+ event.preventDefault();
84
+ }
85
+
86
+ stripe.createToken(card).then(function(result) {
87
+ if (result.error) {
88
+ // Inform the user if there was an error.
89
+ var errorElement = document.getElementById('card-errors');
90
+ errorElement.textContent = result.error.message;
91
+ } else {
92
+ // Send the token to your server.
93
+ stripeTokenHandler(form, result.token);
94
+ }
95
+ });
96
+ }
97
+
98
+ var hideAll = function() {
99
+ var forms = document.getElementsByClassName('payment-form')
100
+ for (var i = 0; i < forms.length; i++) {
101
+ forms[i].style.display = 'none'
102
+ }
103
+ }
104
+
105
+ var setHiddenValue = function(form, className, value) {
106
+ var field = form.getElementsByClassName(className)[0]
107
+ field.value = value
108
+ }
109
+
110
+ this.show = function(id) {
111
+ this.form = document.getElementById(id)
112
+
113
+ if(!this.form) {
114
+ console.error("Could not find form with id ("+id+")")
115
+ return
116
+ }
117
+
118
+ hideAll()
119
+ this.form.style.display = ''
120
+ card.unmount()
121
+ card.mount('#' + id + "_card") // Add an instance of the card Element into the `card-element` <div>.
122
+ }
123
+
124
+ this.setData = function(options) {
125
+ if(!this.form) {
126
+ console.error("Form must be shown first")
127
+ return
128
+ }
129
+
130
+ var defaults = ['plan_id', 'user_id']
131
+ for (var i = 0; i < defaults.length; i++) {
132
+ var key = defaults[i]
133
+ if(options[key]) setHiddenValue(this.form, 'form_'+key, options[key])
134
+ }
135
+ }
136
+
137
+ this.submit = function() {
138
+ onSubmit(this.form)
139
+ }
140
+
141
+ init.call(this)
142
+ }
143
+
144
+ window.SPElements = SPElements
145
+ })
146
+
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The CSS shown here will not be introduced in the Quickstart guide, but shows
3
+ * how you can use CSS to style your Element's container.
4
+ */
5
+ .StripeElement {
6
+ box-sizing: border-box;
7
+
8
+ height: 40px;
9
+
10
+ padding: 10px 12px;
11
+
12
+ border: 1px solid transparent;
13
+ border-radius: 4px;
14
+ background-color: white;
15
+
16
+ box-shadow: 0 1px 3px 0 #e6ebf1;
17
+ -webkit-transition: box-shadow 150ms ease;
18
+ transition: box-shadow 150ms ease;
19
+ }
20
+
21
+ .StripeElement--focus {
22
+ box-shadow: 0 1px 3px 0 #cfd7df;
23
+ }
24
+
25
+ .StripeElement--invalid {
26
+ border-color: #fa755a;
27
+ }
28
+
29
+ .StripeElement--webkit-autofill {
30
+ background-color: #fefde5 !important;
31
+ }
@@ -0,0 +1,173 @@
1
+ module SaasPayments
2
+ module SubscriptionConcerns
3
+ Stripe.api_key = SaasPayments.config.stripe_secret_key
4
+
5
+ class FailedPayment < StandardError; end
6
+ class ActionRequired < StandardError; end
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ def sign_up_for_plan user, plan
11
+ customer = get_customer user
12
+ sub = update_subscription customer, plan
13
+ render_success
14
+ rescue ActionRequired
15
+ render_action_required
16
+ rescue FailedPayment
17
+ render_card_failure
18
+ rescue Stripe::CardError
19
+ render_card_failure
20
+ end
21
+
22
+ def change_plan user, plan
23
+ customer = get_customer user
24
+ sub = customer.subscription(remote: true)
25
+ update_plan sub, customer, plan
26
+ render_success
27
+ rescue StandardError
28
+ render_error
29
+ end
30
+
31
+ def cancel_at_period_end user
32
+ customer = get_customer user
33
+ sub = customer.subscription(remote: true)
34
+
35
+ # Customer has an existing subscription, change plans
36
+ remote_sub = Stripe::Subscription.update(sub[:id], {
37
+ cancel_at_period_end: true
38
+ })
39
+
40
+ customer.subscription.update(Subscription.from_stripe(remote_sub))
41
+ render_success
42
+ rescue StandardError
43
+ render_error
44
+ end
45
+
46
+ def resume_subscription user
47
+ customer = get_customer user
48
+ sub = customer.subscription
49
+ remote_sub = Stripe::Subscription.update(sub.stripe_id, {
50
+ cancel_at_period_end: false
51
+ })
52
+
53
+ customer.subscription.update(Subscription.from_stripe(remote_sub))
54
+ render_success
55
+ rescue StandardError
56
+ render_error
57
+ end
58
+
59
+ def cancel_now user
60
+ customer = get_customer user
61
+ Stripe::Subscription.delete(customer.subscription.stripe_id)
62
+ end
63
+
64
+
65
+ private
66
+
67
+ def render_success
68
+ respond_to do |format|
69
+ format.html { redirect_to SaasPayments.config.account_path }
70
+ format.json { render json: { message: "success" } }
71
+ end
72
+ end
73
+
74
+ def render_error
75
+ format.html {
76
+ flash[:sp_error] = 'Failed to update plan'
77
+ redirect_back fallback_location: root_path
78
+ }
79
+ format.json { render json: { message: "update_failed" }, status: :bad_request }
80
+ end
81
+
82
+ def render_action_required
83
+ respond_to do |format|
84
+ format.html {
85
+ flash[:sp_notice] = 'Payment requires customer action'
86
+ redirect_to SaasPayments.successPath
87
+ }
88
+ format.json { render json: { message: "action_required" } }
89
+ end
90
+ end
91
+
92
+ def render_card_failure
93
+ respond_to do |format|
94
+ format.html {
95
+ flash[:sp_error] = 'Failed to process card'
96
+ redirect_back fallback_location: root_path
97
+ }
98
+ format.json { render json: { message: "card_error" }, status: :bad_request }
99
+ end
100
+ end
101
+
102
+ def get_customer user
103
+ # Try to find a customer in the database by the user id
104
+ customer = SaasPayments::Customer.find_by_user_id(user.id)
105
+
106
+ # If no customer exists, create a new one
107
+ if !customer.present?
108
+ # Create a new customer in stripe
109
+ c = Stripe::Customer.create({
110
+ email: user.email,
111
+ source: params.require(:stripeToken),
112
+ metadata: { user_id: user.id }
113
+ })
114
+
115
+ # Create a new customer locally that maps to the stripe customer
116
+ customer = Customer.create(Customer.from_stripe(c).merge({ user_id: user.id }))
117
+ end
118
+ customer
119
+ end
120
+
121
+ def update_subscription customer, plan
122
+ sub = customer.subscription(remote: true)
123
+ if sub.present?
124
+ update_plan sub, customer, plan
125
+ else
126
+ sub = create_subscription customer, plan
127
+ end
128
+
129
+ sub
130
+ end
131
+
132
+ def update_plan sub, customer, plan
133
+ # Customer has an existing subscription, change plans
134
+ remote_sub = Stripe::Subscription.update(sub[:id], {
135
+ cancel_at_period_end: false,
136
+ items: [{
137
+ id: sub[:items][:data][0][:id],
138
+ plan: plan.stripe_id
139
+ }]
140
+ })
141
+
142
+ customer.subscription.update(Subscription.from_stripe(remote_sub))
143
+ end
144
+
145
+ def create_subscription customer, plan
146
+ # Customer does not have a subscription, sign them up
147
+ sub = Stripe::Subscription.create({
148
+ customer: customer.stripe_id,
149
+ items: [{ plan: plan.stripe_id }]
150
+ })
151
+ complete_subscription sub
152
+ end
153
+
154
+ def complete_subscription sub
155
+ if incomplete?(sub)
156
+ raise FailedPayment if payment_intent(sub, :requires_payment_method)
157
+ raise ActionRequired if payment_intent(sub, :requires_action)
158
+ end
159
+
160
+ # Successful payment, or trialling: provision the subscription
161
+ # Create a local subscription
162
+ Subscription.create(Subscription.from_stripe(sub))
163
+ end
164
+
165
+ def incomplete? sub
166
+ sub[:status] == "incomplete"
167
+ end
168
+
169
+ def payment_intent sub, intent
170
+ sub[:latest_invoice][:payment_intent][:status] == intent.to_s
171
+ end
172
+ end
173
+ end