rvindi 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/gem-push.yml +45 -0
  3. data/.gitignore +9 -0
  4. data/.rubocop.yml +13 -0
  5. data/.tool-versions +1 -0
  6. data/CHANGELOG.md +5 -0
  7. data/CODE_OF_CONDUCT.md +84 -0
  8. data/Gemfile +21 -0
  9. data/Gemfile.lock +134 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +205 -0
  12. data/Rakefile +8 -0
  13. data/bin/console +22 -0
  14. data/bin/setup +8 -0
  15. data/lib/rvindi.rb +3 -0
  16. data/lib/vindi/core_extensions/her_save_only_changed_attrs.rb +73 -0
  17. data/lib/vindi/core_extensions/her_with_query_filter.rb +69 -0
  18. data/lib/vindi/middleware/rate_limit_validation.rb +23 -0
  19. data/lib/vindi/middleware/response_parser.rb +50 -0
  20. data/lib/vindi/models/address.rb +9 -0
  21. data/lib/vindi/models/bill.rb +26 -0
  22. data/lib/vindi/models/bill_item.rb +18 -0
  23. data/lib/vindi/models/charge.rb +9 -0
  24. data/lib/vindi/models/customer.rb +29 -0
  25. data/lib/vindi/models/discount.rb +8 -0
  26. data/lib/vindi/models/issue.rb +8 -0
  27. data/lib/vindi/models/model.rb +77 -0
  28. data/lib/vindi/models/notification.rb +8 -0
  29. data/lib/vindi/models/payment_method.rb +8 -0
  30. data/lib/vindi/models/payment_profile.rb +9 -0
  31. data/lib/vindi/models/period.rb +8 -0
  32. data/lib/vindi/models/plan.rb +90 -0
  33. data/lib/vindi/models/plan_item.rb +11 -0
  34. data/lib/vindi/models/pricing_schema.rb +8 -0
  35. data/lib/vindi/models/product.rb +25 -0
  36. data/lib/vindi/models/product_item.rb +8 -0
  37. data/lib/vindi/models/subscription.rb +48 -0
  38. data/lib/vindi/models/transaction.rb +8 -0
  39. data/lib/vindi/rate_limit.rb +18 -0
  40. data/lib/vindi/version.rb +5 -0
  41. data/lib/vindi.rb +80 -0
  42. data/rvindi.gemspec +30 -0
  43. metadata +141 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreExtensions
4
+ # WARNING: monkey patch (https://github.com/remi/her/blob/master/lib/her/model/relation.rb#L34)
5
+ #
6
+ # The `where` clause must be adapted to vindi requirements:
7
+ # https://atendimento.vindi.com.br/hc/pt-br/articles/204163150
8
+ module HerWithQueryFilter
9
+ # Add a query string parameter
10
+ #
11
+ # @example
12
+ # @users = User.where(contains: { name: 'Gandalf' })
13
+ # # Fetched via GET "/users?query=name:Gandalf"
14
+ #
15
+ # @example
16
+ # @users = User.active.where(gt: { created_at: Time.zone.yesterday })
17
+ # # Fetched via GET "/users?query=status=active created_at>2021-01-01"
18
+ def where(params = {})
19
+ return self if params.blank? && !@_fetch.nil?
20
+
21
+ clone.tap do |r|
22
+ r.params = r.params.merge(params)
23
+
24
+ # Default params, as order and page number, will always be used.
25
+ default_params = extract_default_params r.params
26
+
27
+ # Query filters is joined into a single param called :query.
28
+ query = [r.params.delete(:query), params_to_query(r.params)].compact.join " "
29
+
30
+ r.params = { query: query }.merge(default_params)
31
+
32
+ r.clear_fetch_cache!
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def extract_default_params(params)
39
+ page = params.delete(:page) || 1
40
+ per_page = params.delete(:per_page) || 25
41
+ sort_by = params.delete(:sort_by) || :created_at
42
+ sort_order = params.delete(:sort_order) || :desc
43
+
44
+ {
45
+ page: page,
46
+ per_page: per_page,
47
+ sort_by: sort_by,
48
+ sort_order: sort_order
49
+ }.delete_if { |_, v| v.nil? }
50
+ end
51
+
52
+ def params_to_query(params)
53
+ params.map do |key, value|
54
+ case key
55
+ when :contains then value.map { |k, v| "#{k}:#{v}" }.last
56
+ when :gt then value.map { |k, v| "#{k}>#{v}" }.last
57
+ when :gteq then value.map { |k, v| "#{k}>=#{v}" }.last
58
+ when :lt then value.map { |k, v| "#{k}<#{v}" }.last
59
+ when :lteq then value.map { |k, v| "#{k}<=#{v}" }.last
60
+ when :not then value.map { |k, v| "-#{k}:#{v}" }.last
61
+ else
62
+ value ? "#{key}=#{value}" : key
63
+ end
64
+ end.join(" ")
65
+ end
66
+ end
67
+ end
68
+
69
+ Her::Model::Relation.prepend CoreExtensions::HerWithQueryFilter
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ module Middleware
5
+ # Saves the rate limts from Vindi responses.
6
+ class RateLimitValidation < Faraday::Middleware
7
+ def call(env)
8
+ raise Vindi::RateLimitError, "Rate limit reached" if rate_limit_reached?
9
+
10
+ @app.call(env)
11
+ end
12
+
13
+ private
14
+
15
+ def rate_limit_reached?
16
+ return false unless Vindi::RateLimit.rate_limit_limit
17
+
18
+ Vindi::RateLimit.rate_limit_limit <= Vindi::RateLimit.rate_limit_remaining &&
19
+ Vindi::RateLimit.rate_limit_reset > Time.now
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ module Middleware
5
+ # Vindi Response Parser
6
+ #
7
+ # Saves metadata info into metadata object.
8
+ class ResponseParser < Faraday::Response::Middleware
9
+ def on_complete(env)
10
+ env[:body] = parse(env[:status] == 204 ? "{}" : env)
11
+
12
+ Vindi::RateLimit.update env[:body].dig :metadata, :rate_limit
13
+ end
14
+
15
+ def parse(env)
16
+ json = Her::Middleware::ParseJSON.new.parse_json env[:body]
17
+ errors = translate_errors_to_activemodel_style(json.delete(:errors) || [])
18
+ metadata = (json.delete(:metadata) || {}).merge(extract_response_headers_info(env[:response_headers]))
19
+
20
+ {
21
+ data: json,
22
+ errors: errors,
23
+ metadata: metadata
24
+ }
25
+ end
26
+
27
+ def translate_errors_to_activemodel_style(errors)
28
+ errors.map do |e|
29
+ {
30
+ attribute: e[:parameter],
31
+ type: e[:id] == "invalid_parameter" ? :invalid : e[:id],
32
+ message: e[:message]
33
+ }
34
+ end
35
+ end
36
+
37
+ def extract_response_headers_info(response_headers)
38
+ {
39
+ items: response_headers[:total],
40
+ link: response_headers[:link],
41
+ rate_limit: {
42
+ limit: response_headers[:"rate-limit-limit"],
43
+ reset: Time.at(response_headers[:"rate-limit-reset"].to_i),
44
+ remaining: response_headers[:"rate-limit-remaining"]
45
+ }
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Address
5
+ #
6
+ class Address < Model
7
+ belongs_to :customer
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Bill
5
+ #
6
+ # @example
7
+ #
8
+ # bill = Vindi::Bill.find(1)
9
+ # bill.charges
10
+ #
11
+ # @example
12
+ #
13
+ # subscription = Vindi::Subscription.find(1)
14
+ # bills = subscription.bills
15
+ #
16
+ class Bill < Model
17
+ belongs_to :customer
18
+ belongs_to :period
19
+ belongs_to :subscription
20
+ belongs_to :payment_profile
21
+ # belongs_to :payment_condition
22
+
23
+ has_many :bill_items
24
+ has_many :charges
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Bill Item
5
+ #
6
+ # @example
7
+ #
8
+ # bill = Vindi::Bill.find(1)
9
+ # bill_items = bill.items
10
+ #
11
+ class BillItem < Model
12
+ belongs_to :product
13
+ belongs_to :product_item
14
+ belongs_to :discount
15
+
16
+ # has_many :usages
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Bill Charges
5
+ #
6
+ class Charge < Model
7
+ belongs_to :bill
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subscription"
4
+
5
+ module Vindi
6
+ # Customers
7
+ #
8
+ # @example
9
+ #
10
+ # customer = Vindi::Customer.find(1)
11
+ # customer.subscriptions
12
+ # customer.subscriptions.active
13
+ #
14
+ # customer = Vindi::Customer.find_by(email: "gandalf@middleearth.com")
15
+ # customer.name
16
+ # customer.name = "Gandalf the White"
17
+ # customer.save
18
+ #
19
+ class Customer < Model
20
+ scope :contains_name, ->(name) { where(contains: { name: name }) }
21
+
22
+ has_one :address, class_name: "Vindi::Address"
23
+ # has_many :subscriptions, class_name: "Vindi::Subscription", parent_as_param: true, group_params_with: :query
24
+
25
+ def subscriptions
26
+ Vindi::Subscription.where(customer_id: id)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Discounts
5
+ #
6
+ class Discount < Model
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Issues
5
+ #
6
+ class Issue < Model
7
+ end
8
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Base model to vindi resources.
5
+ class Model
6
+ include Her::Model
7
+
8
+ # @example Active subscriptions
9
+ # @subscriptions = Vindi::Subscription.active
10
+ #
11
+ scope :active, -> { where(status: :active) }
12
+ scope :inactive, -> { where(status: :inactive) }
13
+ scope :canceled, -> { where(status: :canceled) }
14
+ scope :archived, -> { where(status: :archived) }
15
+
16
+ # @example Active Customers paginated
17
+ #
18
+ # @customers = Vindi::Customer.active.per_page(5).page(2)
19
+ #
20
+ scope :page, ->(page) { where(page: page) }
21
+ scope :per_page, ->(per_page) { where(per_page: per_page) }
22
+
23
+ # @example Active Customers ordered by name
24
+ # @customers = Vindi::Customer.active.order_by(:name, :asc)
25
+ #
26
+ scope :order_by, ->(attr_name, order = :desc) { where(sort_by: attr_name, sort_order: order) }
27
+
28
+ parse_root_in_json true, format: :active_model_serializers
29
+
30
+ store_metadata :_metadata
31
+
32
+ # Archive a record.
33
+ #
34
+ # @example Archive a customer
35
+ #
36
+ # Vindi::Customer.find_by(email: "sarumanthewhite@middlearth.io").archive!
37
+ #
38
+ def archive!
39
+ destroy
40
+ end
41
+
42
+ # @private
43
+ def valid?
44
+ super && response_errors.empty?
45
+ end
46
+
47
+ class << self
48
+ # @example First Customer
49
+ # @customer = Vindi::Customer.first
50
+ #
51
+ # @example First two customers
52
+ # @customers = Vindi::Customer.first(2)
53
+ #
54
+ def first(limit = 1)
55
+ records = order_by(:created_at, :asc).per_page(limit)
56
+
57
+ return records[0] if limit == 1
58
+
59
+ records
60
+ end
61
+
62
+ # @example Last customer
63
+ # @customer = Vindi::Customer.last
64
+ #
65
+ # @example Last two customers
66
+ # @customers = Vindi::Customer.last(2)
67
+ #
68
+ def last(limit = 1)
69
+ records = order_by(:created_at, :desc).per_page(limit)
70
+
71
+ return records[0] if limit == 1
72
+
73
+ records
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Notifications
5
+ #
6
+ class Notitication < Model
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Payment Methods
5
+ #
6
+ class PaymentMethod < Model
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Payment Profiles
5
+ #
6
+ class PaymentProfile < Model
7
+ belongs_to :customer
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Periods
5
+ #
6
+ class Period < Model
7
+ end
8
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Plans
5
+ #
6
+ # @example List active plans
7
+ #
8
+ # plans = Vindi::Plan.active
9
+ #
10
+ # @example Create a recurring plan
11
+ #
12
+ # plan = Vindi::Plan.new.tap do |p|
13
+ # p.name = "Monthly Plan"
14
+ # p.description = "This plan will be renewed every month in the same day"
15
+ # p.period = "monthly"
16
+ # p.recurring = true
17
+ # p.code = 1
18
+ # p.plan_items = [
19
+ # {
20
+ # cycles: nil,
21
+ # product_id: 1
22
+ # }
23
+ # ]
24
+ # end
25
+ #
26
+ # @example Create an yearly plan with installments
27
+ #
28
+ # plan = Vindi::Plan.new.tap do |p|
29
+ # p.name = "Yearly Plan"
30
+ # p.description = "This plan will be paid in 12 installments"
31
+ # p.period = "yearly"
32
+ # p.billing_cycles = 1
33
+ # p.installments = 12
34
+ # p.code = 1
35
+ # p.plan_items = [
36
+ # {
37
+ # cycles: nil,
38
+ # product_id: 1
39
+ # }
40
+ # ]
41
+ # end
42
+ #
43
+ class Plan < Model
44
+ # has_many :plan_items
45
+ # has_many :products, through: :plan_items
46
+
47
+ scope :recurring, -> { where(billing_cycles: nil) }
48
+
49
+ after_initialize :set_defaults
50
+
51
+ def recurring=(value)
52
+ self.billing_cycles = value ? nil : 0
53
+ end
54
+
55
+ def period=(value)
56
+ raise "invalid period" unless %w[monthly quarterly biannually yearly].include? value.to_s
57
+
58
+ send "set_#{value}"
59
+ end
60
+
61
+ private
62
+
63
+ def set_defaults
64
+ self.billing_trigger_type = "beginning_of_period"
65
+ self.billing_trigger_day = 0
66
+ self.installments = 1
67
+ self.status = "active"
68
+ end
69
+
70
+ def set_monthly
71
+ self.interval = "months"
72
+ self.interval_count = 1
73
+ end
74
+
75
+ def set_quarterly
76
+ self.interval = "months"
77
+ self.interval_count = 3
78
+ end
79
+
80
+ def set_biannually
81
+ self.interval = "months"
82
+ self.interval_count = 6
83
+ end
84
+
85
+ def set_yearly
86
+ self.interval = "months"
87
+ self.interval_count = 12
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Plan Items
5
+ #
6
+ class PlanItem < Model
7
+ belongs_to :plan
8
+
9
+ has_one :product
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Pricing Schema
5
+ #
6
+ class PricingSchema < Model
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Products
5
+ #
6
+ # @example Active productts
7
+ #
8
+ # products = Vindi::Product.active
9
+ #
10
+ # @example Create a product
11
+ #
12
+ # palantir = Vindi::Product.new.tap do |p|
13
+ # p.code = "palantir"
14
+ # p.name = "Palantir"
15
+ # p.description = "The Twitch of Istari folk"
16
+ # p.pricing_schema = { price: 42.42 }
17
+ # p.save
18
+ # end
19
+ #
20
+ class Product < Model
21
+ belongs_to :pricing_schema
22
+
23
+ has_many :plans
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Product Items
5
+ #
6
+ class ProductItem < Model
7
+ end
8
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Customer Subscriptions
5
+ #
6
+ # @example Subscribe a customer to a plan
7
+ #
8
+ # @subscription = Vindi::Subscription.new.tap do |s|
9
+ # s.customer_id = customer.id
10
+ # s.plan_id = plan.id
11
+ # s.payment_method_code = "credit_card"
12
+ # s.save
13
+ # end
14
+ #
15
+ class Subscription < Model
16
+ belongs_to :customer
17
+ belongs_to :plan
18
+
19
+ attributes :plan_id, :customer_id, :payment_method_code
20
+
21
+ validates :plan_id, :customer_id, :payment_method_code, presence: true
22
+
23
+ scope :inactive, -> { canceled }
24
+
25
+ # @example Cancel a subscription
26
+ #
27
+ # @subscription = Vindi::Customer.find(1).subscriptions.active.last
28
+ # @subscription.cancel!
29
+ #
30
+ def cancel!
31
+ destroy
32
+ end
33
+
34
+ # @example Reactivate a subscription
35
+ #
36
+ # @subscription = Vindi::Customer.find(1).subscriptions.inactive.last
37
+ # @subscription.reactivate!
38
+ #
39
+ def reactivate!
40
+ # REVIEW: There's another way to do this using `custom_post` but the result breaks the normal
41
+ # flow because the API returns the root resource as singular name and HER expects to be a plural.
42
+
43
+ self.class.post_raw(:reactivate, id: id) do |parsed_data, _|
44
+ assign_attributes parsed_data[:data][self.class.collection_path.singularize.to_sym]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ # Transactitons
5
+ #
6
+ class Transactiton < Model
7
+ end
8
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ class RateLimitError < StandardError; end
5
+
6
+ # Vindi API calls has a rate limit.
7
+ class RateLimit
8
+ class << self
9
+ attr_accessor :rate_limit_limit, :rate_limit_remaining, :rate_limit_reset
10
+
11
+ def update(rate = {})
12
+ @rate_limit_limit = rate[:limit].to_i
13
+ @rate_limit_remaining = rate[:remaining].to_i
14
+ @rate_limit_reset = Time.at(rate[:reset].to_i)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vindi
4
+ VERSION = "0.0.3"
5
+ end
data/lib/vindi.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-configurable"
4
+
5
+ require "faraday"
6
+ require "faraday_middleware"
7
+ require "her"
8
+
9
+ require_relative "vindi/version"
10
+ require_relative "vindi/rate_limit"
11
+
12
+ require_relative "vindi/middleware/rate_limit_validation"
13
+ require_relative "vindi/middleware/response_parser"
14
+
15
+ require_relative "vindi/core_extensions/her_with_query_filter"
16
+ require_relative "vindi/core_extensions/her_save_only_changed_attrs"
17
+
18
+ module Vindi # :nodoc:
19
+ extend Dry::Configurable
20
+
21
+ class Error < StandardError; end
22
+
23
+ RESOURCE_MODELS = Dir[File.expand_path("vindi/models/**/*.rb", File.dirname(__FILE__))].freeze
24
+
25
+ RESOURCE_MODELS.each do |f|
26
+ autoload File.basename(f, ".rb").camelcase.to_sym, f
27
+ end
28
+
29
+ VINDI_API_URL = "https://app.vindi.com.br/api/v1"
30
+ VINDI_SANDBOX_API_URL = "https://sandbox-app.vindi.com.br/api/v1"
31
+
32
+ # Set sandbox to true in dev mode.
33
+ setting :sandbox, false
34
+ # Set the API KEY to assign the API calls.
35
+ setting :api_key
36
+ # Validates incoming Vindi Webhook calls with the given secret name.
37
+ setting :webhook_secret_name
38
+ # Validates incoming Vindi Webhook calls with the given secret password.
39
+ setting :webhook_secret_password
40
+
41
+ # @example
42
+ # Vindi.configure do |config|
43
+ # config.sandbox = true
44
+ # config.api_key = "MY API KEY"
45
+ # config.webhook_secret_name = "A SECRET NAMEE"
46
+ # config.webhook_secret_password = "A SECRET PASSWORD"
47
+ # end
48
+ #
49
+ def self.configure
50
+ super
51
+
52
+ her_setup
53
+ end
54
+
55
+ def self.api_url
56
+ return VINDI_SANDBOX_API_URL if config.sandbox
57
+
58
+ VINDI_API_URL
59
+ end
60
+
61
+ # @private
62
+ def self.her_setup
63
+ Her::API.setup url: Vindi.api_url do |conn|
64
+ conn.headers["User-Agent"] = "wedsonlima/rvindi #{Vindi::VERSION}"
65
+
66
+ conn.basic_auth config.api_key, ""
67
+
68
+ # Request
69
+ conn.use ::Vindi::Middleware::RateLimitValidation
70
+ conn.request :json
71
+
72
+ # Response
73
+ conn.response :json, content_type: /\bjson$/
74
+ conn.use ::Vindi::Middleware::ResponseParser
75
+
76
+ # Adapter
77
+ conn.adapter Faraday.default_adapter
78
+ end
79
+ end
80
+ end