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,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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ VERSION = '1.0.0'
5
+ end
@@ -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