go_puff-tax_service 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ module Actions
6
+ class Commit < GoPuff::TaxService::Tax
7
+ def call
8
+ return empty_products_response if products.empty?
9
+
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ def body_params
16
+ @body_params ||= {
17
+ locationId: order.location_id,
18
+ userId: user_identifier,
19
+ orderId: order.id_obfuscated.to_s,
20
+ deliveryAddress: delivery_address,
21
+ products: products
22
+ }
23
+ end
24
+
25
+ def serialize_purchase(purchase)
26
+ super(purchase).merge(isAlcohol: purchase.decorate.kind == 'alcohol')
27
+ end
28
+
29
+ def fees_line_items
30
+ super.each { |fee| fee[:isAlcohol] = (fee[:productId] == ALCOHOL_FEE_ID) }
31
+ end
32
+
33
+ def endpoint
34
+ '/api/taxes/commit'
35
+ end
36
+
37
+ def empty_products_response
38
+ Response::Commit.empty_response(error: 'Requested tax for empty products')
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ module Actions
6
+ class Get < GoPuff::TaxService::Tax
7
+ def initialize(order, address: nil, purchases: nil, fees_kind: 'all')
8
+ super(order, address: address)
9
+
10
+ @purchases = purchases
11
+ @fees_kind = fees_kind
12
+ end
13
+
14
+ def call
15
+ return empty_products_response if products.empty?
16
+ return empty_address_response if delivery_address_empty?
17
+
18
+ super
19
+ end
20
+
21
+ private
22
+
23
+ def purchases_to_tax
24
+ return super unless @purchases
25
+
26
+ @purchases_to_tax ||= @purchases
27
+ end
28
+
29
+ def body_params
30
+ @body_params ||= {
31
+ products: products,
32
+ locationId: order.location_id,
33
+ deliveryAddress: delivery_address
34
+ }
35
+ end
36
+
37
+ def endpoint
38
+ '/api/taxes'
39
+ end
40
+
41
+ def empty_products_response
42
+ Response::Get.empty_response(error: 'Requested tax for empty products')
43
+ end
44
+
45
+ def empty_address_response
46
+ Response::Get.empty_response(error: 'Requested tax for empty address')
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ class Configuration
6
+ attr_writer :base_url, :user_agent_header
7
+ attr_accessor :authentication, :logger, :requests
8
+
9
+ def initialize
10
+ @base_url = nil
11
+
12
+ @authentication = Struct.new(:token_provider, :on_unauthorized).new
13
+
14
+ @requests = Struct.new(
15
+ :max_retries,
16
+ :retry_wait_time,
17
+ :retryable_exceptions
18
+ ).new(3, 0.5, [EOFError, Errno::ECONNRESET])
19
+
20
+ @logger = ::Rails.logger if defined?(::Rails) && ::Rails&.logger
21
+ end
22
+
23
+ def user_agent_header
24
+ return @user_agent_header if @user_agent_header.present?
25
+
26
+ raise "Missing configuration 'user_agent_header' for TaxService gem"
27
+ end
28
+
29
+ def base_url
30
+ return @base_url if @base_url.present?
31
+
32
+ raise "Missing configuration 'base_url' for TaxService gem"
33
+ end
34
+
35
+ def token_provider
36
+ return @authentication.token_provider if @authentication.token_provider.is_a?(Proc)
37
+
38
+ raise "Missing configuration 'authentication.token_provider' for TaxService gem"
39
+ end
40
+
41
+ def on_unauthorized
42
+ return -> {} unless @authentication.on_unauthorized.is_a?(Proc)
43
+
44
+ @authentication.on_unauthorized
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ module Errors
6
+ class Base < StandardError; end
7
+
8
+ class FeesKindInvalid < Base
9
+ def initialize(kind)
10
+ super("Invalid fees_kind attribute: #{kind}")
11
+ end
12
+ end
13
+
14
+ class Unauthorized < Base
15
+ def initialize(token = nil)
16
+ super("Authentication failed with token: #{token}")
17
+ end
18
+ end
19
+
20
+ class RequestFailed < Base
21
+ def initialize(error, attempts)
22
+ super("Request to tax service failed after #{attempts} attempts. Error: #{error&.class} #{error&.message}")
23
+ end
24
+ end
25
+
26
+ class ValidationFailed < Base
27
+ def initialize(response, params)
28
+ @response = response
29
+ @params = params
30
+
31
+ super(error_message)
32
+ end
33
+
34
+ def error_message
35
+ errors = extract_messages_from_error_response(@response)
36
+
37
+ "Tax service request failed: #{errors}. Sent params: #{@params}"
38
+ end
39
+
40
+ def extract_messages_from_error_response(error_hash)
41
+ return '' unless error_hash.is_a?(Hash)
42
+
43
+ messages = []
44
+
45
+ error_hash.each do |key, value|
46
+ if key.to_sym == :message
47
+ messages << value
48
+ elsif value.is_a?(Hash)
49
+ messages << extract_messages_from_error_response(value)
50
+ end
51
+ end
52
+
53
+ messages.flatten.join('; ')
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ module Response
6
+ class Base
7
+ def initialize(raw_response)
8
+ @raw_response = raw_response
9
+ end
10
+
11
+ def self.empty_response(**options)
12
+ new({ success: false, error: nil, data: {}, **options })
13
+ end
14
+
15
+ def [](key)
16
+ return data[key] unless data.is_a?(Hash)
17
+
18
+ data[possible_key_for(key)]
19
+ end
20
+
21
+ def success?
22
+ @raw_response[:success]
23
+ end
24
+
25
+ def error?
26
+ error.present?
27
+ end
28
+
29
+ def error
30
+ @raw_response[:error]
31
+ end
32
+
33
+ def data
34
+ @raw_response[:data]
35
+ end
36
+
37
+ private
38
+
39
+ def possible_key_for(key)
40
+ [key.to_sym, key.to_s.camelize(:lower).to_sym].find do |possible_key|
41
+ data&.key?(possible_key)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'go_puff/tax_service/response/base'
4
+
5
+ module GoPuff
6
+ module TaxService
7
+ module Response
8
+ class Cancel < Base; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'go_puff/tax_service/response/get'
4
+
5
+ module GoPuff
6
+ module TaxService
7
+ module Response
8
+ class Commit < Get; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'go_puff/tax_service/response/base'
4
+
5
+ module GoPuff
6
+ module TaxService
7
+ module Response
8
+ class Get < Base
9
+ attr_reader :products_taxes
10
+
11
+ def initialize(*args)
12
+ super
13
+
14
+ parse_attributes!
15
+ end
16
+
17
+ def tax_for_product(product_id)
18
+ @products_taxes[product_id] || @products_taxes[product_id.to_s]
19
+ end
20
+
21
+ %w[totalTaxCollected taxToAddToTotal taxBreakdown].each do |attribute|
22
+ define_method(attribute.underscore.to_sym) { data[attribute.to_sym] }
23
+ end
24
+
25
+ private
26
+
27
+ def parse_attributes!
28
+ parse_products_taxes!
29
+ end
30
+
31
+ def parse_products_taxes!
32
+ return unless data.try(:[], :products).is_a?(Array)
33
+
34
+ @products_taxes = data[:products].each_with_object({}) do |product, products_hash|
35
+ products_hash[product[:productId]] = product[:taxAmount]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'retryable'
5
+
6
+ require 'go_puff/tax_service/errors'
7
+ require 'go_puff/tax_service/response/get'
8
+ require 'go_puff/tax_service/response/commit'
9
+ require 'go_puff/tax_service/response/cancel'
10
+
11
+ module GoPuff
12
+ module TaxService
13
+ class Tax
14
+ TIMEOUT = 5
15
+ FEES_KINDS = %w[all non_alcohol alcohol none].freeze
16
+ ALCOHOL_FEE_ID = 'ALCOHOL_FEE'
17
+
18
+ attr_reader :order
19
+
20
+ def initialize(order, address: nil)
21
+ @order = order
22
+ @address = address
23
+ @fees_kind = 'all'
24
+ end
25
+
26
+ def call
27
+ raise Errors::FeesKindInvalid, @fees_kind unless FEES_KINDS.include?(@fees_kind)
28
+
29
+ request_tax_service
30
+
31
+ return create_response_object(@response.body) if @response.success?
32
+
33
+ order.tax_service_validation_failed = true
34
+
35
+ response_for_error
36
+ end
37
+
38
+ private
39
+
40
+ def configuration
41
+ GoPuff::TaxService.configuration
42
+ end
43
+
44
+ def endpoint
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def body_params
49
+ raise NotImplementedError
50
+ end
51
+
52
+ def request_tax_service
53
+ url = "#{configuration.base_url}#{endpoint}"
54
+
55
+ Retryable.retryable(**retryable_config) do
56
+ @response = GoPuff::Http::Client.new(url, timeout: TIMEOUT).post(headers: headers, params: body_params)
57
+ end
58
+ rescue *configuration.requests.retryable_exceptions => e
59
+ raise Errors::RequestFailed.new(e, retryable_config[:tries])
60
+ end
61
+
62
+ def retryable_config
63
+ {
64
+ on: configuration.requests.retryable_exceptions,
65
+ tries: configuration.requests.max_retries,
66
+ sleep: configuration.requests.retry_wait_time
67
+ }
68
+ end
69
+
70
+ def headers
71
+ {
72
+ 'Accept' => 'application/json',
73
+ 'Content-Type' => 'application/json',
74
+ 'Authorization' => "Bearer #{configuration.token_provider.call}"
75
+ }
76
+ end
77
+
78
+ def empty_response
79
+ { success: false, error: nil, data: {} }
80
+ end
81
+
82
+ def response_class_name
83
+ "GoPuff::TaxService::Response::#{self.class.name.demodulize}"
84
+ end
85
+
86
+ def create_response_object(response)
87
+ response_class_name.constantize.new(response)
88
+ rescue NameError
89
+ response
90
+ end
91
+
92
+ def response_for_error
93
+ case @response.status.to_i
94
+ when 401
95
+ configuration.on_unauthorized.call
96
+
97
+ raise Errors::Unauthorized
98
+ when 400
99
+ raise Errors::ValidationFailed.new(@response.body, body_params)
100
+ else
101
+ create_response_object(empty_response)
102
+ end
103
+ end
104
+
105
+ def serialize_purchase(purchase)
106
+ {
107
+ productId: purchase.product_id,
108
+ quantity: purchase.amount,
109
+ price: purchase.price.to_f,
110
+ priceIncludesTax: price_includes_tax?
111
+ }
112
+ end
113
+
114
+ def purchases_to_tax
115
+ @purchases_to_tax ||= order.purchases
116
+ end
117
+
118
+ def products
119
+ @products ||= purchases_to_tax.map { |purchase| serialize_purchase(purchase) } + fees_line_items
120
+ end
121
+
122
+ def alcohol_fees
123
+ {
124
+ ALCOHOL_FEE_ID => order.alcohol_fee.to_f
125
+ }
126
+ end
127
+
128
+ def non_alcohol_fees
129
+ {
130
+ 'DELIVERY_FEE' => order.delivery.to_f,
131
+ 'SERVICE_FEE' => order.service_fee.to_f,
132
+ 'SMALL_ORDER_FEE' => order.small_order_fee.to_f,
133
+ 'PRIORITY_FEE' => order.priority_fee.to_f
134
+ }
135
+ end
136
+
137
+ def fees_line_items
138
+ case @fees_kind
139
+ when 'all'
140
+ non_alcohol_fees.merge(alcohol_fees)
141
+ when 'non_alcohol'
142
+ non_alcohol_fees
143
+ when 'alcohol'
144
+ alcohol_fees
145
+ else
146
+ {}
147
+ end.map do |key, value|
148
+ next unless value.positive?
149
+
150
+ {
151
+ productId: key,
152
+ quantity: 1,
153
+ price: value,
154
+ priceIncludesTax: price_includes_tax?
155
+ }
156
+ end.compact
157
+ end
158
+
159
+ def price_includes_tax?
160
+ !['us', :us].include?(order.country_code)
161
+ end
162
+
163
+ def delivery_zone_address
164
+ @delivery_zone_address ||= order&.delivery_zone&.delivery_zone_address
165
+ end
166
+
167
+ def delivery_address
168
+ return {} unless @address
169
+
170
+ get_address_for(@address, fallback: delivery_zone_address)
171
+ end
172
+
173
+ def delivery_address_empty?
174
+ delivery_address[:line1].blank?
175
+ end
176
+
177
+ def warehouse_address
178
+ return {} unless delivery_zone_address
179
+
180
+ get_address_for(delivery_zone_address, fallback: @address)
181
+ end
182
+
183
+ def get_address_for(resource, fallback:)
184
+ {
185
+ line1: resource.address.presence || fallback&.address,
186
+ city: resource.city.presence || fallback&.city,
187
+ state: resource.state.presence || fallback&.state,
188
+ country: order.country_code&.upcase,
189
+ postalCode: resource.zip.presence || fallback&.zip
190
+ }.compact
191
+ end
192
+
193
+ def user_identifier
194
+ return 'backoffice' if order.third_party?
195
+ return 'external' unless order.gopuff?
196
+
197
+ (order&.user&.gim_id.presence || order&.user_id).to_s
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPuff
4
+ module TaxService
5
+ VERSION = '1.5.0'
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/inflector'
5
+ require 'active_support/core_ext/object'
6
+
7
+ require 'go_puff/tax_service/tax'
8
+ require 'go_puff/tax_service/errors'
9
+ require 'go_puff/tax_service/configuration'
10
+
11
+ require 'go_puff/tax_service/actions/get'
12
+ require 'go_puff/tax_service/actions/commit'
13
+ require 'go_puff/tax_service/actions/cancel'
14
+
15
+ require 'go_puff/http/client'
16
+
17
+ module GoPuff
18
+ module TaxService
19
+ class << self
20
+ attr_accessor :configuration
21
+ end
22
+
23
+ def self.configure
24
+ self.configuration ||= Configuration.new
25
+
26
+ yield configuration if block_given?
27
+
28
+ GoPuff::Http.config.logger = configuration.logger
29
+ GoPuff::Http.config.user_agent_header = configuration.user_agent_header
30
+ GoPuff::Http.config.adapter = :net_http_persistent
31
+ end
32
+ end
33
+ end