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