reji 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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