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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ module ManagesSubscriptions
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :subscriptions, -> { order(created_at: :desc) }, class_name: 'Reji::Subscription'
9
+ end
10
+
11
+ # Begin creating a new subscription.
12
+ def new_subscription(name, plans)
13
+ SubscriptionBuilder.new(self, name, plans)
14
+ end
15
+
16
+ # Determine if the Stripe model is on trial.
17
+ def on_trial(name = 'default', plan = nil)
18
+ return true if name == 'default' && plan.nil? && self.on_generic_trial
19
+
20
+ subscription = self.subscription(name)
21
+
22
+ return false unless subscription && subscription.on_trial
23
+
24
+ plan ? subscription.has_plan(plan) : true
25
+ end
26
+
27
+ # Determine if the Stripe model is on a "generic" trial at the model level.
28
+ def on_generic_trial
29
+ !! self.trial_ends_at && self.trial_ends_at.future?
30
+ end
31
+
32
+ # Determine if the Stripe model has a given subscription.
33
+ def subscribed(name = 'default', plan = nil)
34
+ subscription = self.subscription(name)
35
+
36
+ return false unless subscription && subscription.valid?
37
+
38
+ plan ? subscription.has_plan(plan) : true
39
+ end
40
+
41
+ # Get a subscription instance by name.
42
+ def subscription(name = 'default')
43
+ self.subscriptions
44
+ .sort_by { |subscription| subscription.created_at.to_i }
45
+ .reverse
46
+ .find { |subscription| subscription.name == name }
47
+ end
48
+
49
+ # Determine if the customer's subscription has an incomplete payment.
50
+ def has_incomplete_payment(name = 'default')
51
+ subscription = self.subscription(name)
52
+
53
+ subscription ? subscription.has_incomplete_payment : false
54
+ end
55
+
56
+ # Determine if the Stripe model is actively subscribed to one of the given plans.
57
+ def subscribed_to_plan(plans, name = 'default')
58
+ subscription = self.subscription(name)
59
+
60
+ return false unless subscription && subscription.valid?
61
+
62
+ plans = [plans] unless plans.instance_of? Array
63
+
64
+ plans.each do |plan|
65
+ return true if subscription.has_plan(plan)
66
+ end
67
+
68
+ false
69
+ end
70
+
71
+ # Determine if the entity has a valid subscription on the given plan.
72
+ def on_plan(plan)
73
+ self.subscriptions.any? { |subscription| subscription.valid && subscription.has_plan(plan) }
74
+ end
75
+
76
+ # Get the tax percentage to apply to the subscription.
77
+ def tax_percentage
78
+ 0
79
+ end
80
+
81
+ # Get the tax rates to apply to the subscription.
82
+ def tax_rates
83
+ {}
84
+ end
85
+
86
+ # Get the tax rates to apply to individual subscription items.
87
+ def plan_tax_rates
88
+ {}
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ module PerformsCharges
5
+ extend ActiveSupport::Concern
6
+
7
+ # Make a "one off" charge on the customer for the given amount.
8
+ def charge(amount, payment_method, options = {})
9
+ options = {
10
+ :confirmation_method => 'automatic',
11
+ :confirm => true,
12
+ :currency => self.preferred_currency,
13
+ }.merge(options)
14
+
15
+ options[:amount] = amount
16
+ options[:payment_method] = payment_method
17
+ options[:customer] = self.stripe_id if self.has_stripe_id
18
+
19
+ payment = Payment.new(
20
+ Stripe::PaymentIntent.create(options, self.stripe_options)
21
+ )
22
+
23
+ payment.validate
24
+
25
+ payment
26
+ end
27
+
28
+ # Refund a customer for a charge.
29
+ def refund(payment_intent, options = {})
30
+ Stripe::Refund.create(
31
+ {:payment_intent => payment_intent}.merge(options),
32
+ self.stripe_options
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ module Prorates
5
+ extend ActiveSupport::Concern
6
+
7
+ # Indicate that the plan change should not be prorated.
8
+ def no_prorate
9
+ @proration_behavior = 'none'
10
+
11
+ self
12
+ end
13
+
14
+ # Indicate that the plan change should be prorated.
15
+ def prorate
16
+ @proration_behavior = 'create_prorations'
17
+
18
+ self
19
+ end
20
+
21
+ # Indicate that the plan change should always be invoiced.
22
+ def always_invoice
23
+ @proration_behavior = 'always_invoice'
24
+ end
25
+
26
+ # Set the prorating behavior.
27
+ def set_proration_behavior(value)
28
+ @proration_behavior = value
29
+
30
+ self
31
+ end
32
+
33
+ # Determine the prorating behavior when updating the subscription.
34
+ def prorate_behavior
35
+ @proration_behavior ||= 'create_prorations'
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class Configuration
5
+ # Stripe Keys
6
+ #
7
+ # The Stripe publishable key and secret key give you access to Stripe's
8
+ # API. The "publishable" key is typically used when interacting with
9
+ # Stripe.js while the "secret" key accesses private API endpoints.
10
+ attr_accessor :key
11
+ attr_accessor :secret
12
+
13
+ # Stripe Webhooks
14
+ #
15
+ # Your Stripe webhook secret is used to prevent unauthorized requests to
16
+ # your Stripe webhook handling controllers. The tolerance setting will
17
+ # check the drift between the current time and the signed request's.
18
+ attr_accessor :webhook
19
+
20
+ # Reji Model
21
+ #
22
+ # This is the model in your application that includes the Billable concern
23
+ # provided by Reji. It will serve as the primary model you use while
24
+ # interacting with Reji related methods, subscriptions, and so on.
25
+ attr_accessor :model
26
+ attr_accessor :model_id
27
+
28
+ # Currency
29
+ #
30
+ # This is the default currency that will be used when generating charges
31
+ # from your application. Of course, you are welcome to use any of the
32
+ # various world currencies that are currently supported via Stripe.
33
+ attr_accessor :currency
34
+
35
+ def initialize
36
+ @key = ENV['STRIPE_KEY']
37
+ @secret = ENV['STRIPE_SECRET']
38
+ @webhook = {
39
+ :secret => ENV['STRIPE_WEBHOOK_SECRET'],
40
+ :tolerance => ENV['STRIPE_WEBHOOK_TOLERANCE'] || 300,
41
+ }
42
+ @model = ENV['REJI_MODEL'] || 'User'
43
+ @model_id = ENV['REJI_MODEL_ID'] || 'user_id'
44
+ @currency = ENV['REJI_CURRENCY'] || 'usd'
45
+ end
46
+ end
47
+
48
+ def self.configuration
49
+ @configuration ||= Configuration.new
50
+ end
51
+
52
+ def self.configuration=(config)
53
+ @configuration = config
54
+ end
55
+
56
+ def self.configure
57
+ yield(configuration)
58
+ end
59
+ end
@@ -0,0 +1,4 @@
1
+ module Reji
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class IncompletePaymentError < StandardError
5
+ attr_accessor :payment
6
+
7
+ def initialize(payment, message = '')
8
+ super(message)
9
+
10
+ @payment = payment
11
+ end
12
+ end
13
+
14
+ class PaymentActionRequiredError < IncompletePaymentError
15
+ def self.incomplete(payment)
16
+ self.new(payment, 'The payment attempt failed because additional action is required before it can be completed.')
17
+ end
18
+ end
19
+
20
+ class PaymentFailureError < IncompletePaymentError
21
+ def self.invalid_payment_method(payment)
22
+ self.new(payment, 'The payment attempt failed because of an invalid payment method.')
23
+ end
24
+ end
25
+
26
+ class CustomerAlreadyCreatedError < StandardError
27
+ def self.exists(owner)
28
+ self.new("#{owner.class.name} is already a Stripe customer with ID #{owner.stripe_id}.")
29
+ end
30
+ end
31
+
32
+ class InvalidCustomerError < StandardError
33
+ def self.not_yet_created(owner)
34
+ self.new("#{owner.class.name} is not a Stripe customer yet. See the create_as_stripe_customer method.")
35
+ end
36
+ end
37
+
38
+ class InvalidPaymentMethodError < StandardError
39
+ def self.invalid_owner(payment_method, owner)
40
+ self.new("The payment method `#{payment_method.id}` does not belong to this customer `#{owner.stripe_id}`.")
41
+ end
42
+ end
43
+
44
+ class InvalidInvoiceError < StandardError
45
+ def self.invalid_owner(invoice, owner)
46
+ self.new("The invoice `#{invoice.id}` does not belong to this customer `#{owner.stripe_id}`.")
47
+ end
48
+ end
49
+
50
+ class SubscriptionUpdateFailureError < StandardError
51
+ def self.incomplete_subscription(subscription)
52
+ self.new("The subscription \"#{subscription.stripe_id}\" cannot be updated because its payment is incomplete.")
53
+ end
54
+
55
+ def self.duplicate_plan(subscription, plan)
56
+ self.new("The plan \"#{plan}\" is already attached to subscription \"#{subscription.stripe_id}\".")
57
+ end
58
+
59
+ def self.cannot_delete_last_plan(subscription)
60
+ self.new("The plan on subscription \"#{subscription.stripe_id}\" cannot be removed because it is the last one.")
61
+ end
62
+ end
63
+
64
+ class AccessDeniedHttpError < StandardError
65
+ end
66
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wicked_pdf'
4
+
5
+ module Reji
6
+ class Invoice
7
+ def initialize(owner, invoice)
8
+ raise Reji::InvalidInvoiceError.invalid_owner(invoice, owner) if owner.stripe_id != invoice.customer
9
+
10
+ @owner = owner
11
+ @invoice = invoice
12
+ @items = nil
13
+ @taxes = nil
14
+ end
15
+
16
+ # Get a date for the invoice.
17
+ def date
18
+ Time.at(@invoice.created ? @invoice.created : @invoice.date)
19
+ end
20
+
21
+ # Get the total amount that was paid (or will be paid).
22
+ def total
23
+ Reji.format_amount(self.raw_total)
24
+ end
25
+
26
+ # Get the raw total amount that was paid (or will be paid).
27
+ def raw_total
28
+ @invoice.total + self.raw_starting_balance
29
+ end
30
+
31
+ # Get the total of the invoice (before discounts).
32
+ def subtotal
33
+ Reji.format_amount(@invoice[:subtotal])
34
+ end
35
+
36
+ # Determine if the account had a starting balance.
37
+ def has_starting_balance
38
+ self.raw_starting_balance < 0
39
+ end
40
+
41
+ # Get the starting balance for the invoice.
42
+ def starting_balance
43
+ Reji.format_amount(self.raw_starting_balance)
44
+ end
45
+
46
+ # Get the raw starting balance for the invoice.
47
+ def raw_starting_balance
48
+ @invoice[:starting_balance] ? @invoice[:starting_balance] : 0
49
+ end
50
+
51
+ # Determine if the invoice has a discount.
52
+ def has_discount
53
+ self.raw_discount > 0
54
+ end
55
+
56
+ # Get the discount amount.
57
+ def discount
58
+ self.format_amount(self.raw_discount)
59
+ end
60
+
61
+ # Get the raw discount amount.
62
+ def raw_discount
63
+ return 0 unless @invoice.discount
64
+
65
+ return (@invoice.subtotal * (self.percent_off / 100)).round.to_i if self.discount_is_percentage
66
+
67
+ self.raw_amount_off
68
+ end
69
+
70
+ # Get the coupon code applied to the invoice.
71
+ def coupon
72
+ return @invoice[:discount][:coupon][:id] if @invoice[:discount]
73
+ end
74
+
75
+ # Determine if the discount is a percentage.
76
+ def discount_is_percentage
77
+ return false unless @invoice[:discount]
78
+
79
+ !! @invoice[:discount][:coupon][:percent_off]
80
+ end
81
+
82
+ # Get the discount percentage for the invoice.
83
+ def percent_off
84
+ self.coupon ? @invoice[:discount][:coupon][:percent_off] : 0
85
+ end
86
+
87
+ # Get the discount amount for the invoice.
88
+ def amount_off
89
+ self.format_amount(self.raw_amount_off)
90
+ end
91
+
92
+ # Get the raw discount amount for the invoice.
93
+ def raw_amount_off
94
+ amount_off = @invoice[:discount][:coupon][:amount_off]
95
+
96
+ amount_off ? amount_off : 0
97
+ end
98
+
99
+ # Get the total tax amount.
100
+ def tax
101
+ self.format_amount(@invoice.tax)
102
+ end
103
+
104
+ # Determine if the invoice has tax applied.
105
+ def has_tax
106
+ line_items = self.invoice_items + self.subscriptions
107
+
108
+ line_items.any? { |item| item.has_tax_rates }
109
+ end
110
+
111
+ # Get the taxes applied to the invoice.
112
+ def taxes
113
+ return @taxes unless @taxes.nil?
114
+
115
+ self.refresh_with_expanded_tax_rates
116
+
117
+ @taxes = @invoice.total_tax_amounts
118
+ .sort_by(&:inclusive)
119
+ .reverse
120
+ .map { |tax_amount| Tax.new(tax_amount.amount, @invoice.currency, tax_amount.tax_rate) }
121
+
122
+ @taxes
123
+ end
124
+
125
+ # Determine if the customer is not exempted from taxes.
126
+ def is_not_tax_exempt
127
+ @invoice[:customer_tax_exempt] == 'none'
128
+ end
129
+
130
+ # Determine if the customer is exempted from taxes.
131
+ def is_tax_exempt
132
+ @invoice[:customer_tax_exempt] == 'exempt'
133
+ end
134
+
135
+ # Determine if reverse charge applies to the customer.
136
+ def reverse_charge_applies
137
+ @invoice[:customer_tax_exempt] == 'reverse'
138
+ end
139
+
140
+ # Get all of the "invoice item" line items.
141
+ def invoice_items
142
+ self.invoice_line_items_by_type('invoiceitem')
143
+ end
144
+
145
+ # Get all of the "subscription" line items.
146
+ def subscriptions
147
+ self.invoice_line_items_by_type('subscription')
148
+ end
149
+
150
+ # Get all of the invoice items by a given type.
151
+ def invoice_line_items_by_type(type)
152
+ if @items.nil?
153
+ self.refresh_with_expanded_tax_rates
154
+
155
+ @items = @invoice.lines.auto_paging_each
156
+ end
157
+
158
+ @items
159
+ .select { |item| item.type == type }
160
+ .map { |item| InvoiceLineItem.new(self, item) }
161
+ end
162
+
163
+ # Get the View instance for the invoice.
164
+ def view(data)
165
+ ActionController::Base.new.render_to_string(
166
+ template: 'receipt',
167
+ locals: data.merge({
168
+ invoice: self,
169
+ owner: self.owner,
170
+ user: self.owner,
171
+ })
172
+ )
173
+ end
174
+
175
+ # Capture the invoice as a PDF and return the raw bytes.
176
+ def pdf(data)
177
+ WickedPdf.new.pdf_from_string(self.view(data))
178
+ end
179
+
180
+ # Create an invoice download response.
181
+ def download(data)
182
+ filename = "#{data[:product]}_#{self.date.month}_#{self.date.year}"
183
+
184
+ self.download_as(filename, data)
185
+ end
186
+
187
+ # Create an invoice download response with a specific filename.
188
+ def download_as(filename, data)
189
+ {:data => self.pdf(data), :filename => filename}
190
+ end
191
+
192
+ # Void the Stripe invoice.
193
+ def void(options = {})
194
+ @invoice = @invoice.void_invoice(options, @owner.stripe_options)
195
+
196
+ self
197
+ end
198
+
199
+ # Get the Stripe model instance.
200
+ def owner
201
+ @owner
202
+ end
203
+
204
+ # Get the Stripe invoice instance.
205
+ def as_stripe_invoice
206
+ @invoice
207
+ end
208
+
209
+ # Dynamically get values from the Stripe invoice.
210
+ def method_missing(key)
211
+ @invoice[key]
212
+ end
213
+
214
+ protected
215
+
216
+ # Refresh the invoice with expanded TaxRate objects.
217
+ def refresh_with_expanded_tax_rates
218
+ if @invoice.id
219
+ @invoice = Stripe::Invoice.retrieve({
220
+ :id => @invoice.id,
221
+ :expand => [
222
+ 'lines.data.tax_amounts.tax_rate',
223
+ 'total_tax_amounts.tax_rate',
224
+ ],
225
+ }, @owner.stripe_options)
226
+ else
227
+ # If no invoice ID is present then assume this is the customer's upcoming invoice...
228
+ @invoice = Stripe::Invoice.upcoming({
229
+ :customer => @owner.stripe_id,
230
+ :expand => [
231
+ 'lines.data.tax_amounts.tax_rate',
232
+ 'total_tax_amounts.tax_rate',
233
+ ],
234
+ }, @owner.stripe_options)
235
+ end
236
+ end
237
+
238
+ # Format the given amount into a displayable currency.
239
+ def format_amount(amount)
240
+ Reji.format_amount(amount, @invoice.currency)
241
+ end
242
+ end
243
+ end