saas_payments 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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