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