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
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reji
|
4
|
+
class SubscriptionBuilder
|
5
|
+
include Reji::InteractsWithPaymentBehavior
|
6
|
+
include Reji::Prorates
|
7
|
+
|
8
|
+
# Create a new subscription builder instance.
|
9
|
+
def initialize(owner, name, plans = [])
|
10
|
+
@owner = owner
|
11
|
+
@name = name
|
12
|
+
@trial_expires # The date and time the trial will expire.
|
13
|
+
@skip_trial = false # Indicates that the trial should end immediately.
|
14
|
+
@billing_cycle_anchor = nil # The date on which the billing cycle should be anchored.
|
15
|
+
@coupon # The coupon code being applied to the customer.
|
16
|
+
@metadata # The metadata to apply to the subscription.
|
17
|
+
@items = {}
|
18
|
+
|
19
|
+
plans = [plans] unless plans.instance_of? Array
|
20
|
+
|
21
|
+
plans.each { |plan| self.plan(plan) }
|
22
|
+
end
|
23
|
+
|
24
|
+
# Set a plan on the subscription builder.
|
25
|
+
def plan(plan, quantity = 1)
|
26
|
+
options = {
|
27
|
+
:plan => plan,
|
28
|
+
:quantity => quantity
|
29
|
+
}
|
30
|
+
|
31
|
+
tax_rates = self.get_plan_tax_rates_for_payload(plan)
|
32
|
+
|
33
|
+
options[:tax_rates] = tax_rates if tax_rates
|
34
|
+
|
35
|
+
@items[plan] = options
|
36
|
+
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
# Specify the quantity of a subscription item.
|
41
|
+
def quantity(quantity, plan = nil)
|
42
|
+
if plan.nil?
|
43
|
+
raise ArgumentError.new('Plan is required when creating multi-plan subscriptions.') if @items.length > 1
|
44
|
+
|
45
|
+
plan = @items.values[0][:plan]
|
46
|
+
end
|
47
|
+
|
48
|
+
self.plan(plan, quantity)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Specify the number of days of the trial.
|
52
|
+
def trial_days(trial_days)
|
53
|
+
@trial_expires = Time.now + trial_days.days
|
54
|
+
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
# Specify the ending date of the trial.
|
59
|
+
def trial_until(trial_until)
|
60
|
+
@trial_expires = trial_until
|
61
|
+
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
# Force the trial to end immediately.
|
66
|
+
def skip_trial
|
67
|
+
@skip_trial = true
|
68
|
+
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
# Change the billing cycle anchor on a plan creation.
|
73
|
+
def anchor_billing_cycle_on(date)
|
74
|
+
@billing_cycle_anchor = date
|
75
|
+
|
76
|
+
self
|
77
|
+
end
|
78
|
+
|
79
|
+
# The coupon to apply to a new subscription.
|
80
|
+
def with_coupon(coupon)
|
81
|
+
@coupon = coupon
|
82
|
+
|
83
|
+
self
|
84
|
+
end
|
85
|
+
|
86
|
+
# The metadata to apply to a new subscription.
|
87
|
+
def with_metadata(metadata)
|
88
|
+
@metadata = metadata
|
89
|
+
|
90
|
+
self
|
91
|
+
end
|
92
|
+
|
93
|
+
# Add a new Stripe subscription to the Stripe model.
|
94
|
+
def add(customer_options = {}, subscription_options = {})
|
95
|
+
self.create(nil, customer_options, subscription_options)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Create a new Stripe subscription.
|
99
|
+
def create(payment_method = nil, customer_options = {}, subscription_options = {})
|
100
|
+
customer = self.get_stripe_customer(payment_method, customer_options)
|
101
|
+
|
102
|
+
payload = {:customer => customer.id}
|
103
|
+
.merge(self.build_payload)
|
104
|
+
.merge(subscription_options)
|
105
|
+
|
106
|
+
stripe_subscription = Stripe::Subscription.create(
|
107
|
+
payload,
|
108
|
+
@owner.stripe_options
|
109
|
+
)
|
110
|
+
|
111
|
+
subscription = @owner.subscriptions.create({
|
112
|
+
:name => @name,
|
113
|
+
:stripe_id => stripe_subscription.id,
|
114
|
+
:stripe_status => stripe_subscription.status,
|
115
|
+
:stripe_plan => stripe_subscription.plan ? stripe_subscription.plan.id : nil,
|
116
|
+
:quantity => stripe_subscription.quantity,
|
117
|
+
:trial_ends_at => @skip_trial ? nil : @trial_expires,
|
118
|
+
:ends_at => nil,
|
119
|
+
})
|
120
|
+
|
121
|
+
stripe_subscription.items.each do |item|
|
122
|
+
subscription.items.create({
|
123
|
+
:stripe_id => item.id,
|
124
|
+
:stripe_plan => item.plan.id,
|
125
|
+
:quantity => item.quantity,
|
126
|
+
})
|
127
|
+
end
|
128
|
+
|
129
|
+
if subscription.has_incomplete_payment
|
130
|
+
Payment.new(stripe_subscription.latest_invoice.payment_intent).validate
|
131
|
+
end
|
132
|
+
|
133
|
+
subscription
|
134
|
+
end
|
135
|
+
|
136
|
+
protected
|
137
|
+
|
138
|
+
# Get the Stripe customer instance for the current user and payment method.
|
139
|
+
def get_stripe_customer(payment_method = nil, options = {})
|
140
|
+
customer = @owner.create_or_get_stripe_customer(options)
|
141
|
+
|
142
|
+
@owner.update_default_payment_method(payment_method) if payment_method
|
143
|
+
|
144
|
+
customer
|
145
|
+
end
|
146
|
+
|
147
|
+
# Build the payload for subscription creation.
|
148
|
+
def build_payload
|
149
|
+
payload = {
|
150
|
+
:billing_cycle_anchor => @billing_cycle_anchor,
|
151
|
+
:coupon => @coupon,
|
152
|
+
:expand => ['latest_invoice.payment_intent'],
|
153
|
+
:metadata => @metadata,
|
154
|
+
:items => @items.values,
|
155
|
+
:payment_behavior => self.payment_behavior,
|
156
|
+
:proration_behavior => self.prorate_behavior,
|
157
|
+
:trial_end => self.get_trial_end_for_payload,
|
158
|
+
:off_session => true,
|
159
|
+
}
|
160
|
+
|
161
|
+
tax_rates = self.get_tax_rates_for_payload
|
162
|
+
|
163
|
+
if tax_rates
|
164
|
+
payload[:default_tax_rates] = tax_rates
|
165
|
+
|
166
|
+
return payload
|
167
|
+
end
|
168
|
+
|
169
|
+
tax_percentage = self.get_tax_percentage_for_payload
|
170
|
+
|
171
|
+
payload[:tax_percent] = tax_percentage if tax_percentage
|
172
|
+
|
173
|
+
payload
|
174
|
+
end
|
175
|
+
|
176
|
+
# Get the trial ending date for the Stripe payload.
|
177
|
+
def get_trial_end_for_payload
|
178
|
+
return 'now' if @skip_trial
|
179
|
+
|
180
|
+
@trial_expires.to_i if @trial_expires
|
181
|
+
end
|
182
|
+
|
183
|
+
# Get the tax percentage for the Stripe payload.
|
184
|
+
def get_tax_percentage_for_payload
|
185
|
+
tax_percentage = @owner.tax_percentage
|
186
|
+
|
187
|
+
tax_percentage if tax_percentage > 0
|
188
|
+
end
|
189
|
+
|
190
|
+
# Get the tax rates for the Stripe payload.
|
191
|
+
def get_tax_rates_for_payload
|
192
|
+
tax_rates = @owner.tax_rates
|
193
|
+
|
194
|
+
tax_rates unless tax_rates.empty?
|
195
|
+
end
|
196
|
+
|
197
|
+
# Get the plan tax rates for the Stripe payload.
|
198
|
+
def get_plan_tax_rates_for_payload(plan)
|
199
|
+
tax_rates = @owner.plan_tax_rates
|
200
|
+
|
201
|
+
unless tax_rates.empty?
|
202
|
+
tax_rates[plan] || nil
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reji
|
4
|
+
class SubscriptionItem < ActiveRecord::Base
|
5
|
+
include Reji::InteractsWithPaymentBehavior
|
6
|
+
include Reji::Prorates
|
7
|
+
|
8
|
+
belongs_to :subscription
|
9
|
+
|
10
|
+
# Increment the quantity of the subscription item.
|
11
|
+
def increment_quantity(count = 1)
|
12
|
+
self.update_quantity(self.quantity + count)
|
13
|
+
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
# Increment the quantity of the subscription item, and invoice immediately.
|
18
|
+
def increment_and_invoice(count = 1)
|
19
|
+
self.always_invoice
|
20
|
+
|
21
|
+
self.increment_quantity(count)
|
22
|
+
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
# Decrement the quantity of the subscription item.
|
27
|
+
def decrement_quantity(count = 1)
|
28
|
+
self.update_quantity([1, self.quantity - count].max)
|
29
|
+
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
# Update the quantity of the subscription item.
|
34
|
+
def update_quantity(quantity)
|
35
|
+
self.subscription.guard_against_incomplete
|
36
|
+
|
37
|
+
stripe_subscription_item = self.as_stripe_subscription_item
|
38
|
+
stripe_subscription_item.quantity = quantity
|
39
|
+
stripe_subscription_item.payment_behavior = self.payment_behavior
|
40
|
+
stripe_subscription_item.proration_behavior = self.prorate_behavior
|
41
|
+
stripe_subscription_item.save
|
42
|
+
|
43
|
+
self.update(quantity: quantity)
|
44
|
+
|
45
|
+
self.subscription.update(quantity: quantity) if self.subscription.has_single_plan
|
46
|
+
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
# Swap the subscription item to a new Stripe plan.
|
51
|
+
def swap(plan, options = {})
|
52
|
+
self.subscription.guard_against_incomplete
|
53
|
+
|
54
|
+
options = {
|
55
|
+
:plan => plan,
|
56
|
+
:quantity => self.quantity,
|
57
|
+
:payment_behavior => self.payment_behavior,
|
58
|
+
:proration_behavior => self.prorate_behavior,
|
59
|
+
:tax_rates => self.subscription.get_plan_tax_rates_for_payload(plan)
|
60
|
+
}.merge(options)
|
61
|
+
|
62
|
+
item = Stripe::SubscriptionItem::update(
|
63
|
+
self.stripe_id,
|
64
|
+
options,
|
65
|
+
self.subscription.owner.stripe_options
|
66
|
+
)
|
67
|
+
|
68
|
+
self.update(stripe_plan: plan, quantity: item.quantity)
|
69
|
+
|
70
|
+
self.subscription.update(stripe_plan: plan, quantity: item.quantity) if self.subscription.has_single_plan
|
71
|
+
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# Swap the subscription item to a new Stripe plan, and invoice immediately.
|
76
|
+
def swap_and_invoice(plan, options = {})
|
77
|
+
self.always_invoice
|
78
|
+
|
79
|
+
self.swap(plan, options)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Update the underlying Stripe subscription item information for the model.
|
83
|
+
def update_stripe_subscription_item(options = {})
|
84
|
+
Stripe::SubscriptionItem.update(
|
85
|
+
self.stripe_id, options, self.subscription.owner.stripe_options
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Get the subscription as a Stripe subscription item object.
|
90
|
+
def as_stripe_subscription_item(expand = {})
|
91
|
+
Stripe::SubscriptionItem.retrieve(
|
92
|
+
{:id => self.stripe_id, :expand => expand},
|
93
|
+
self.subscription.owner.stripe_options
|
94
|
+
)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
data/lib/reji/tax.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reji
|
4
|
+
class Tax
|
5
|
+
def initialize(amount, currency, tax_rate)
|
6
|
+
@amount = amount
|
7
|
+
@currency = currency
|
8
|
+
@tax_rate = tax_rate
|
9
|
+
end
|
10
|
+
|
11
|
+
# Get the applied currency.
|
12
|
+
def currency
|
13
|
+
@currency
|
14
|
+
end
|
15
|
+
|
16
|
+
# Get the total tax that was paid (or will be paid).
|
17
|
+
def amount
|
18
|
+
self.format_amount(@amount)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Get the raw total tax that was paid (or will be paid).
|
22
|
+
def raw_amount
|
23
|
+
@amount
|
24
|
+
end
|
25
|
+
|
26
|
+
# Determine if the tax is inclusive or not.
|
27
|
+
def is_inclusive
|
28
|
+
@tax_rate.inclusive
|
29
|
+
end
|
30
|
+
|
31
|
+
# Stripe::TaxRate
|
32
|
+
def tax_rate
|
33
|
+
@tax_rate
|
34
|
+
end
|
35
|
+
|
36
|
+
# Dynamically get values from the Stripe TaxRate.
|
37
|
+
def method_missing(key)
|
38
|
+
@tax_rate[key]
|
39
|
+
end
|
40
|
+
|
41
|
+
protected
|
42
|
+
|
43
|
+
# Format the given amount into a displayable currency.
|
44
|
+
def format_amount(amount)
|
45
|
+
Reji.format_amount(amount, @currency)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/reji/version.rb
ADDED
data/reji.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$:.push File.expand_path('lib', __dir__)
|
4
|
+
|
5
|
+
require 'reji/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = 'reji'
|
9
|
+
s.version = Reji::VERSION
|
10
|
+
s.author = ['Cuong Giang']
|
11
|
+
s.email = ['thaicuong.giang@gmail.com']
|
12
|
+
s.homepage = 'https://github.com/cuonggt/reji'
|
13
|
+
s.summary = "Reji provides an expressive, fluent interface to Stripe's subscription billing services."
|
14
|
+
s.description = "Reji provides an expressive, fluent interface to Stripe's subscription billing services."
|
15
|
+
s.license = 'MIT'
|
16
|
+
|
17
|
+
s.required_ruby_version = Gem::Requirement.new('>= 2.4.0')
|
18
|
+
|
19
|
+
s.files = `git ls-files`.split("\n")
|
20
|
+
s.require_paths = ['lib']
|
21
|
+
s.test_files = `git ls-files -- {spec}/*`.split("\n")
|
22
|
+
|
23
|
+
s.add_dependency 'stripe', '>= 5.0'
|
24
|
+
s.add_dependency 'money', '>= 6.0'
|
25
|
+
s.add_dependency 'railties', '>= 5.0'
|
26
|
+
s.add_dependency 'activerecord', '>= 5.0'
|
27
|
+
s.add_dependency 'actionmailer', '>= 5.0'
|
28
|
+
s.add_dependency 'wicked_pdf'
|
29
|
+
s.add_dependency 'wkhtmltopdf-binary'
|
30
|
+
s.add_development_dependency 'rspec-rails', '~> 4.0.1'
|
31
|
+
s.add_development_dependency 'sqlite3', '~> 1.4.2'
|
32
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class User < ActiveRecord::Base
|
4
|
+
include Reji::Billable
|
5
|
+
|
6
|
+
def tax_rates
|
7
|
+
@tax_rates || {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def plan_tax_rates
|
11
|
+
@plan_tax_rates || {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def plan_tax_rates=(value)
|
15
|
+
@plan_tax_rates = value
|
16
|
+
end
|
17
|
+
|
18
|
+
def tax_rates=(value)
|
19
|
+
@tax_rates = value
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/all'
|
4
|
+
require 'reji'
|
5
|
+
|
6
|
+
module Dummy
|
7
|
+
APP_ROOT = File.expand_path('..', __FILE__).freeze
|
8
|
+
|
9
|
+
I18n.enforce_available_locales = true
|
10
|
+
|
11
|
+
class Application < Rails::Application
|
12
|
+
config.action_controller.allow_forgery_protection = false
|
13
|
+
config.action_controller.perform_caching = false
|
14
|
+
config.action_dispatch.show_exceptions = false
|
15
|
+
config.action_mailer.default_url_options = { host: 'dummy.example.com' }
|
16
|
+
config.action_mailer.delivery_method = :test
|
17
|
+
config.active_support.deprecation = :stderr
|
18
|
+
config.active_support.test_order = :random
|
19
|
+
config.cache_classes = true
|
20
|
+
config.consider_all_requests_local = true
|
21
|
+
config.eager_load = false
|
22
|
+
config.encoding = 'utf-8'
|
23
|
+
config.paths['app/controllers'] << "#{APP_ROOT}/app/controllers"
|
24
|
+
config.paths['app/models'] << "#{APP_ROOT}/app/models"
|
25
|
+
config.paths['app/views'] << "#{APP_ROOT}/app/views"
|
26
|
+
config.paths['config/database'] = "#{APP_ROOT}/config/database.yml"
|
27
|
+
config.paths['log'] = 'tmp/log/development.log'
|
28
|
+
|
29
|
+
config.paths.add 'config/routes.rb', with: "#{APP_ROOT}/config/routes.rb"
|
30
|
+
config.secret_key_base = 'SECRET_KEY_BASE'
|
31
|
+
|
32
|
+
if config.active_record.sqlite3.respond_to?(:represent_boolean_as_integer)
|
33
|
+
if Rails::VERSION::MAJOR < 6
|
34
|
+
config.active_record.sqlite3.represent_boolean_as_integer = true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
if Rails::VERSION::MAJOR >= 6
|
39
|
+
config.action_mailer.delivery_job = 'ActionMailer::MailDeliveryJob'
|
40
|
+
end
|
41
|
+
|
42
|
+
config.active_job.queue_adapter = :inline
|
43
|
+
|
44
|
+
def require_environment!
|
45
|
+
initialize!
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize!(&block)
|
49
|
+
FileUtils.mkdir_p(Rails.root.join('db').to_s)
|
50
|
+
super unless @initialized
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|