reji 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.gitattributes +4 -0
- data/.gitignore +15 -0
- data/.travis.yml +28 -0
- data/Appraisals +17 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +133 -0
- data/LICENSE +20 -0
- data/README.md +1285 -0
- data/Rakefile +21 -0
- data/app/controllers/reji/payment_controller.rb +31 -0
- data/app/controllers/reji/webhook_controller.rb +170 -0
- data/app/views/payment.html.erb +228 -0
- data/app/views/receipt.html.erb +250 -0
- data/bin/setup +12 -0
- data/config/routes.rb +6 -0
- data/gemfiles/rails_5.0.gemfile +13 -0
- data/gemfiles/rails_5.1.gemfile +13 -0
- data/gemfiles/rails_5.2.gemfile +13 -0
- data/gemfiles/rails_6.0.gemfile +13 -0
- data/lib/generators/reji/install/install_generator.rb +69 -0
- data/lib/generators/reji/install/templates/db/migrate/add_reji_to_users.rb.erb +16 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscription_items.rb.erb +19 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscriptions.rb.erb +22 -0
- data/lib/generators/reji/install/templates/reji.rb +36 -0
- data/lib/reji.rb +75 -0
- data/lib/reji/billable.rb +13 -0
- data/lib/reji/concerns/interacts_with_payment_behavior.rb +33 -0
- data/lib/reji/concerns/manages_customer.rb +113 -0
- data/lib/reji/concerns/manages_invoices.rb +136 -0
- data/lib/reji/concerns/manages_payment_methods.rb +202 -0
- data/lib/reji/concerns/manages_subscriptions.rb +91 -0
- data/lib/reji/concerns/performs_charges.rb +36 -0
- data/lib/reji/concerns/prorates.rb +38 -0
- data/lib/reji/configuration.rb +59 -0
- data/lib/reji/engine.rb +4 -0
- data/lib/reji/errors.rb +66 -0
- data/lib/reji/invoice.rb +243 -0
- data/lib/reji/invoice_line_item.rb +98 -0
- data/lib/reji/payment.rb +61 -0
- data/lib/reji/payment_method.rb +32 -0
- data/lib/reji/subscription.rb +567 -0
- data/lib/reji/subscription_builder.rb +206 -0
- data/lib/reji/subscription_item.rb +97 -0
- data/lib/reji/tax.rb +48 -0
- data/lib/reji/version.rb +5 -0
- data/reji.gemspec +32 -0
- data/spec/dummy/app/models/user.rb +21 -0
- data/spec/dummy/application.rb +53 -0
- data/spec/dummy/config/database.yml +11 -0
- data/spec/dummy/db/schema.rb +40 -0
- data/spec/feature/charges_spec.rb +67 -0
- data/spec/feature/customer_spec.rb +23 -0
- data/spec/feature/invoices_spec.rb +73 -0
- data/spec/feature/multiplan_subscriptions_spec.rb +319 -0
- data/spec/feature/payment_methods_spec.rb +149 -0
- data/spec/feature/pending_updates_spec.rb +77 -0
- data/spec/feature/subscriptions_spec.rb +650 -0
- data/spec/feature/webhooks_spec.rb +162 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/feature_helpers.rb +39 -0
- data/spec/unit/customer_spec.rb +54 -0
- data/spec/unit/invoice_line_item_spec.rb +72 -0
- data/spec/unit/invoice_spec.rb +192 -0
- data/spec/unit/payment_spec.rb +33 -0
- data/spec/unit/subscription_spec.rb +103 -0
- metadata +237 -0
data/Rakefile
ADDED
@@ -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>
|