reji 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +14 -0
  3. data/.gitattributes +4 -0
  4. data/.gitignore +15 -0
  5. data/.travis.yml +28 -0
  6. data/Appraisals +17 -0
  7. data/Gemfile +8 -0
  8. data/Gemfile.lock +133 -0
  9. data/LICENSE +20 -0
  10. data/README.md +1285 -0
  11. data/Rakefile +21 -0
  12. data/app/controllers/reji/payment_controller.rb +31 -0
  13. data/app/controllers/reji/webhook_controller.rb +170 -0
  14. data/app/views/payment.html.erb +228 -0
  15. data/app/views/receipt.html.erb +250 -0
  16. data/bin/setup +12 -0
  17. data/config/routes.rb +6 -0
  18. data/gemfiles/rails_5.0.gemfile +13 -0
  19. data/gemfiles/rails_5.1.gemfile +13 -0
  20. data/gemfiles/rails_5.2.gemfile +13 -0
  21. data/gemfiles/rails_6.0.gemfile +13 -0
  22. data/lib/generators/reji/install/install_generator.rb +69 -0
  23. data/lib/generators/reji/install/templates/db/migrate/add_reji_to_users.rb.erb +16 -0
  24. data/lib/generators/reji/install/templates/db/migrate/create_subscription_items.rb.erb +19 -0
  25. data/lib/generators/reji/install/templates/db/migrate/create_subscriptions.rb.erb +22 -0
  26. data/lib/generators/reji/install/templates/reji.rb +36 -0
  27. data/lib/reji.rb +75 -0
  28. data/lib/reji/billable.rb +13 -0
  29. data/lib/reji/concerns/interacts_with_payment_behavior.rb +33 -0
  30. data/lib/reji/concerns/manages_customer.rb +113 -0
  31. data/lib/reji/concerns/manages_invoices.rb +136 -0
  32. data/lib/reji/concerns/manages_payment_methods.rb +202 -0
  33. data/lib/reji/concerns/manages_subscriptions.rb +91 -0
  34. data/lib/reji/concerns/performs_charges.rb +36 -0
  35. data/lib/reji/concerns/prorates.rb +38 -0
  36. data/lib/reji/configuration.rb +59 -0
  37. data/lib/reji/engine.rb +4 -0
  38. data/lib/reji/errors.rb +66 -0
  39. data/lib/reji/invoice.rb +243 -0
  40. data/lib/reji/invoice_line_item.rb +98 -0
  41. data/lib/reji/payment.rb +61 -0
  42. data/lib/reji/payment_method.rb +32 -0
  43. data/lib/reji/subscription.rb +567 -0
  44. data/lib/reji/subscription_builder.rb +206 -0
  45. data/lib/reji/subscription_item.rb +97 -0
  46. data/lib/reji/tax.rb +48 -0
  47. data/lib/reji/version.rb +5 -0
  48. data/reji.gemspec +32 -0
  49. data/spec/dummy/app/models/user.rb +21 -0
  50. data/spec/dummy/application.rb +53 -0
  51. data/spec/dummy/config/database.yml +11 -0
  52. data/spec/dummy/db/schema.rb +40 -0
  53. data/spec/feature/charges_spec.rb +67 -0
  54. data/spec/feature/customer_spec.rb +23 -0
  55. data/spec/feature/invoices_spec.rb +73 -0
  56. data/spec/feature/multiplan_subscriptions_spec.rb +319 -0
  57. data/spec/feature/payment_methods_spec.rb +149 -0
  58. data/spec/feature/pending_updates_spec.rb +77 -0
  59. data/spec/feature/subscriptions_spec.rb +650 -0
  60. data/spec/feature/webhooks_spec.rb +162 -0
  61. data/spec/spec_helper.rb +27 -0
  62. data/spec/support/feature_helpers.rb +39 -0
  63. data/spec/unit/customer_spec.rb +54 -0
  64. data/spec/unit/invoice_line_item_spec.rb +72 -0
  65. data/spec/unit/invoice_spec.rb +192 -0
  66. data/spec/unit/payment_spec.rb +33 -0
  67. data/spec/unit/subscription_spec.rb +103 -0
  68. metadata +237 -0
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+ require 'bundler/setup'
5
+ require 'bundler/gem_tasks'
6
+
7
+ require 'rake'
8
+ require 'rspec/core/rake_task'
9
+
10
+ namespace :dummy do
11
+ require_relative 'spec/dummy/application'
12
+ Dummy::Application.load_tasks
13
+ end
14
+
15
+ desc 'Run specs'
16
+ RSpec::Core::RakeTask.new('spec') do |task|
17
+ task.verbose = false
18
+ end
19
+
20
+ desc 'Run the specs and acceptance tests'
21
+ task default: %w(spec)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class PaymentController < ActionController::Base
5
+ before_action :verify_redirect_url
6
+
7
+ def show
8
+ @stripe_key = Reji.configuration.key
9
+
10
+ @payment = Reji::Payment.new(
11
+ Stripe::PaymentIntent.retrieve(
12
+ params[:id], Reji.stripe_options
13
+ )
14
+ )
15
+
16
+ @redirect = params[:redirect]
17
+
18
+ render template: 'payment'
19
+ end
20
+
21
+ private
22
+
23
+ def verify_redirect_url
24
+ return if params[:redirect].blank?
25
+
26
+ url = URI(params[:redirect])
27
+
28
+ raise ActionController::Forbidden.new('Redirect host mismatch.') if url.host.blank? || url.host != URI(request.original_url).host
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class WebhookController < ActionController::Base
5
+ before_action :verify_webhook_signature
6
+
7
+ def handle_webhook
8
+ payload = JSON.parse(request.body.read)
9
+
10
+ type = payload['type']
11
+
12
+ return self.missing_method if type.nil?
13
+
14
+ method = "handle_#{payload['type'].gsub('.', '_')}"
15
+
16
+ self.respond_to?(method, true) ?
17
+ self.send(method, payload) :
18
+ self.missing_method
19
+ end
20
+
21
+ protected
22
+
23
+ # Handle customer subscription updated.
24
+ def handle_customer_subscription_updated(payload)
25
+ user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'customer'))
26
+
27
+ return self.success_method if user.nil?
28
+
29
+ data = payload.dig('data', 'object')
30
+
31
+ return self.success_method if data.nil?
32
+
33
+ user.subscriptions
34
+ .select { |subscription| subscription.stripe_id == data['id'] }
35
+ .each do |subscription|
36
+ if data['status'] == 'incomplete_expired'
37
+ subscription.items.destroy_all
38
+ subscription.destroy
39
+
40
+ return self.success_method
41
+ end
42
+
43
+ # Plan...
44
+ subscription.stripe_plan = data.dig('plan', 'id')
45
+
46
+ # Quantity...
47
+ subscription.quantity = data['quantity']
48
+
49
+ # Trial ending date...
50
+ unless data['trial_end'].nil?
51
+ if subscription.trial_ends_at.nil? || subscription.trial_ends_at.to_i != data['trial_end']
52
+ subscription.trial_ends_at = Time.at(data['trial_end'])
53
+ end
54
+ end
55
+
56
+ # Cancellation date...
57
+ unless data['cancel_at_period_end'].nil?
58
+ if data['cancel_at_period_end']
59
+ subscription.ends_at = subscription.on_trial ?
60
+ subscription.trial_ends_at :
61
+ Time.at(data['cancel_at_period_end'])
62
+ else
63
+ subscription.ends_at = nil
64
+ end
65
+ end
66
+
67
+ # Status...
68
+ unless data['status'].nil?
69
+ subscription.stripe_status = data['status']
70
+ end
71
+
72
+ subscription.save
73
+
74
+ # Update subscription items...
75
+ if data.key?('items')
76
+ plans = []
77
+
78
+ items = data.dig('items', 'data')
79
+
80
+ unless items.blank?
81
+ items.each do |item|
82
+ plans << item.dig('plan', 'id')
83
+
84
+ subscription_item = subscription.items.find_or_create_by({:stripe_id => item['id']}) do |subscription_item|
85
+ subscription_item.stripe_plan = item.dig('plan', 'id')
86
+ subscription_item.quantity = item['quantity']
87
+ end
88
+ end
89
+ end
90
+
91
+ # Delete items that aren't attached to the subscription anymore...
92
+ subscription.items.where('stripe_plan NOT IN (?)', plans).destroy_all
93
+ end
94
+ end
95
+
96
+ self.success_method
97
+ end
98
+
99
+ # Handle a cancelled customer from a Stripe subscription.
100
+ def handle_customer_subscription_deleted(payload)
101
+ user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'customer'))
102
+
103
+ unless user.nil?
104
+ user.subscriptions
105
+ .select { |subscription| subscription.stripe_id == payload.dig('data', 'object', 'id') }
106
+ .each { |subscription| subscription.mark_as_cancelled }
107
+ end
108
+
109
+ self.success_method
110
+ end
111
+
112
+ # Handle customer updated.
113
+ def handle_customer_updated(payload)
114
+ user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'id'))
115
+
116
+ user.update_default_payment_method_from_stripe unless user.nil?
117
+
118
+ self.success_method
119
+ end
120
+
121
+ # Handle deleted customer.
122
+ def handle_customer_deleted(payload)
123
+ user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'id'))
124
+
125
+ unless user.nil?
126
+ user.subscriptions.each { |subscription| subscription.skip_trial.mark_as_cancelled }
127
+
128
+ user.update({
129
+ :stripe_id => nil,
130
+ :trial_ends_at => nil,
131
+ :card_brand => nil,
132
+ :card_last_four => nil,
133
+ })
134
+ end
135
+
136
+ self.success_method
137
+ end
138
+
139
+ # Get the billable entity instance by Stripe ID.
140
+ def get_user_by_stripe_id(stripe_id)
141
+ Reji.find_billable(stripe_id)
142
+ end
143
+
144
+ # Handle successful calls on the controller.
145
+ def success_method
146
+ render plain: 'Webhook Handled', status: 200
147
+ end
148
+
149
+ # Handle calls to missing methods on the controller.
150
+ def missing_method
151
+ head :ok
152
+ end
153
+
154
+ private
155
+
156
+ def verify_webhook_signature
157
+ return if Reji.configuration.webhook[:secret].blank?
158
+
159
+ begin
160
+ Stripe::Webhook.construct_event(
161
+ request.body.read,
162
+ request.env['HTTP_STRIPE_SIGNATURE'],
163
+ Reji.configuration.webhook[:secret],
164
+ )
165
+ rescue Stripe::SignatureVerificationError => e
166
+ raise AccessDeniedHttpError.new(e.message)
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,228 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>" class="h-full">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+
7
+ <title>Payment Confirmation</title>
8
+
9
+ <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
10
+
11
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>
12
+ <script src="https://js.stripe.com/v3"></script>
13
+ </head>
14
+ <body class="font-sans text-gray-600 bg-gray-200 leading-normal p-4 h-full">
15
+ <div id="app" class="h-full md:flex md:justify-center md:items-center">
16
+ <div class="w-full max-w-lg">
17
+ <!-- Status Messages -->
18
+ <p class="flex items-center mb-4 bg-red-100 border border-red-200 px-5 py-2 rounded-lg text-red-500" v-if="errorMessage">
19
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="flex-shrink-0 w-6 h-6">
20
+ <path class="fill-current text-red-300" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z"/>
21
+ <path class="fill-current text-red-500" d="M12 18a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm1-5.9c-.13 1.2-1.88 1.2-2 0l-.5-5a1 1 0 0 1 1-1.1h1a1 1 0 0 1 1 1.1l-.5 5z"/>
22
+ </svg>
23
+
24
+ <span class="ml-3">@{{ errorMessage }}</span>
25
+ </p>
26
+
27
+ <p class="flex items-center mb-4 bg-green-100 border border-green-200 px-5 py-4 rounded-lg text-green-700" v-if="paymentProcessed && successMessage">
28
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="flex-shrink-0 w-6 h-6">
29
+ <circle cx="12" cy="12" r="10" class="fill-current text-green-300"/>
30
+ <path class="fill-current text-green-500" d="M10 14.59l6.3-6.3a1 1 0 0 1 1.4 1.42l-7 7a1 1 0 0 1-1.4 0l-3-3a1 1 0 0 1 1.4-1.42l2.3 2.3z"/>
31
+ </svg>
32
+
33
+ <span class="ml-3">@{{ successMessage }}</span>
34
+ </p>
35
+
36
+ <div class="bg-white rounded-lg shadow-xl p-4 sm:py-6 sm:px-10 mb-5">
37
+ <% if @payment.is_succeeded %>
38
+ <h1 class="text-xl mt-2 mb-4 text-gray-700">
39
+ Payment Successful
40
+ </h1>
41
+
42
+ <p class="mb-6">
43
+ This payment was already successfully confirmed.
44
+ </p>
45
+ <% elsif @payment.is_cancelleed %>
46
+ <h1 class="text-xl mt-2 mb-4 text-gray-700">
47
+ Payment Cancelled
48
+ </h1>
49
+
50
+ <p class="mb-6">
51
+ This payment was cancelled.
52
+ </p>
53
+ <% else %>
54
+ <div id="payment-elements" v-if="! paymentProcessed">
55
+ <!-- Payment Method Form -->
56
+ <div v-show="requiresPaymentMethod">
57
+ <!-- Instructions -->
58
+ <h1 class="text-xl mt-2 mb-4 text-gray-700">
59
+ Confirm your <%= @payment.amount %> payment
60
+ </h1>
61
+
62
+ <p class="mb-6">
63
+ Extra confirmation is needed to process your payment. Please confirm your payment by filling out your payment details below.
64
+ </p>
65
+
66
+ <!-- Name -->
67
+ <label for="cardholder-name" class="inline-block text-sm text-gray-700 font-semibold mb-2">
68
+ Full name
69
+ </label>
70
+
71
+ <input
72
+ id="cardholder-name"
73
+ type="text" placeholder="Jane Doe"
74
+ required
75
+ class="inline-block bg-gray-200 border border-gray-400 rounded-lg w-full px-4 py-3 mb-3 focus:outline-none"
76
+ v-model="name"
77
+ />
78
+
79
+ <!-- Card -->
80
+ <label for="card-element" class="inline-block text-sm text-gray-700 font-semibold mb-2">
81
+ Card
82
+ </label>
83
+
84
+ <div id="card-element" class="bg-gray-200 border border-gray-400 rounded-lg p-4 mb-6"></div>
85
+
86
+ <!-- Pay Button -->
87
+ <button
88
+ id="card-button"
89
+ class="inline-block w-full px-4 py-3 mb-4 text-white rounded-lg hover:bg-blue-500"
90
+ :class="{ 'bg-blue-400': paymentProcessing, 'bg-blue-600': ! paymentProcessing }"
91
+ @click="addPaymentMethod"
92
+ :disabled="paymentProcessing"
93
+ >
94
+ Pay <%= @payment.amount %>
95
+ </button>
96
+ </div>
97
+ </div>
98
+ <% end %>
99
+
100
+ <button @click="goBack" ref="goBackButton" data-redirect="<%= @redirect || '/' %>"
101
+ class="inline-block w-full px-4 py-3 bg-gray-200 hover:bg-gray-300 text-center text-gray-700 rounded-lg">
102
+ Go back
103
+ </button>
104
+ </div>
105
+
106
+ <p class="text-center text-gray-500 text-sm">
107
+ © <%= Time.now.year %>. All rights reserved.
108
+ </p>
109
+ </div>
110
+ </div>
111
+
112
+ <script>
113
+ window.stripe = Stripe('<%= @stripe_key %>');
114
+
115
+ var app = new Vue({
116
+ el: '#app',
117
+
118
+ data: {
119
+ name: '',
120
+ cardElement: null,
121
+ paymentProcessing: false,
122
+ paymentProcessed: false,
123
+ requiresPaymentMethod: <%= @payment.requires_payment_method ? 'true' : 'false' %>,
124
+ requiresAction: <%= @payment.requires_action ? 'true' : 'false' %>,
125
+ successMessage: '',
126
+ errorMessage: ''
127
+ },
128
+
129
+ <% if ! @payment.is_succeeded && ! @payment.is_cancelleed && ! @payment.requires_action %>
130
+ mounted: function () {
131
+ this.configureStripe();
132
+ },
133
+ <% end %>
134
+
135
+ methods: {
136
+ addPaymentMethod: function () {
137
+ var self = this;
138
+
139
+ this.paymentProcessing = true;
140
+ this.paymentProcessed = false;
141
+ this.successMessage = '';
142
+ this.errorMessage = '';
143
+
144
+ stripe.confirmCardPayment(
145
+ '<%= @payment.client_secret %>', {
146
+ payment_method: {
147
+ card: this.cardElement,
148
+ billing_details: { name: this.name }
149
+ }
150
+ }
151
+ ).then(function (result) {
152
+ self.paymentProcessing = false;
153
+
154
+ if (result.error) {
155
+ if (result.error.code === 'parameter_invalid_empty' &&
156
+ result.error.param === 'payment_method_data[billing_details][name]') {
157
+ self.errorMessage = 'Please provide your name.';
158
+ } else {
159
+ self.errorMessage = result.error.message;
160
+ }
161
+ } else {
162
+ self.paymentProcessed = true;
163
+
164
+ self.successMessage = 'The payment was successful.';
165
+ }
166
+ });
167
+ },
168
+
169
+ confirmPaymentMethod: function () {
170
+ var self = this;
171
+
172
+ this.paymentProcessing = true;
173
+ this.paymentProcessed = false;
174
+ this.successMessage = '';
175
+ this.errorMessage = '';
176
+
177
+ stripe.confirmCardPayment(
178
+ '<%= @payment.client_secret %>', {
179
+ payment_method: '<%= @payment.payment_method %>'
180
+ }
181
+ ).then(function (result) {
182
+ self.paymentProcessing = false;
183
+
184
+ if (result.error) {
185
+ self.errorMessage = result.error.message;
186
+
187
+ if (result.error.code === 'payment_intent_authentication_failure') {
188
+ self.requestPaymentMethod();
189
+ }
190
+ } else {
191
+ self.paymentProcessed = true;
192
+
193
+ self.successMessage = 'The payment was successful.';
194
+ }
195
+ });
196
+ },
197
+
198
+ requestPaymentMethod: function () {
199
+ this.configureStripe();
200
+
201
+ this.requiresPaymentMethod = true;
202
+ this.requiresAction = false;
203
+ },
204
+
205
+ configureStripe: function () {
206
+ const elements = stripe.elements();
207
+
208
+ this.cardElement = elements.create('card');
209
+ this.cardElement.mount('#card-element');
210
+ },
211
+
212
+ goBack: function () {
213
+ var self = this;
214
+ var button = this.$refs.goBackButton;
215
+ var redirect = button.dataset.redirect;
216
+
217
+ if (self.successMessage || self.errorMessage) {
218
+ redirect.searchParams.append('message', self.successMessage ? self.successMessage : self.errorMessage);
219
+ redirect.searchParams.append('success', !! self.successMessage);
220
+ }
221
+
222
+ window.location.href = redirect;
223
+ },
224
+ },
225
+ })
226
+ </script>
227
+ </body>
228
+ </html>