go_puff-tax_service 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.bundle/config.example +2 -0
- data/.github/CODEOWNERS +5 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- data/.github/workflows/ci.yml +126 -0
- data/.gitignore +15 -0
- data/.rubocop.yml +85 -0
- data/Dockerfile +22 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +124 -0
- data/README.md +86 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/dip.yml +23 -0
- data/docker-compose.yml +13 -0
- data/go_puff-tax_service.gemspec +41 -0
- data/lib/go_puff/tax_service/actions/cancel.rb +19 -0
- data/lib/go_puff/tax_service/actions/commit.rb +43 -0
- data/lib/go_puff/tax_service/actions/get.rb +51 -0
- data/lib/go_puff/tax_service/configuration.rb +48 -0
- data/lib/go_puff/tax_service/errors.rb +58 -0
- data/lib/go_puff/tax_service/response/base.rb +47 -0
- data/lib/go_puff/tax_service/response/cancel.rb +11 -0
- data/lib/go_puff/tax_service/response/commit.rb +11 -0
- data/lib/go_puff/tax_service/response/get.rb +41 -0
- data/lib/go_puff/tax_service/tax.rb +201 -0
- data/lib/go_puff/tax_service/version.rb +7 -0
- data/lib/go_puff/tax_service.rb +33 -0
- metadata +240 -0
@@ -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,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,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
|