go_puff-tax_service 1.5.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.
@@ -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