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