reji 1.0.0

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 (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>