lightspeed_pos 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +5 -5
  2. data/.mailmap +4 -0
  3. data/.rubocop.yml +32 -24
  4. data/.rubocop_todo.yml +284 -0
  5. data/.travis.yml +5 -3
  6. data/Gemfile +2 -0
  7. data/README.markdown +85 -28
  8. data/Rakefile +2 -0
  9. data/bin/console +33 -6
  10. data/lib/lightspeed/account.rb +50 -43
  11. data/lib/lightspeed/accounts.rb +24 -0
  12. data/lib/lightspeed/categories.rb +7 -5
  13. data/lib/lightspeed/category.rb +18 -7
  14. data/lib/lightspeed/client.rb +41 -27
  15. data/lib/lightspeed/collection.rb +214 -0
  16. data/lib/lightspeed/customer.rb +36 -0
  17. data/lib/lightspeed/customers.rb +11 -0
  18. data/lib/lightspeed/employee.rb +27 -0
  19. data/lib/lightspeed/employees.rb +10 -0
  20. data/lib/lightspeed/error.rb +17 -0
  21. data/lib/lightspeed/image.rb +37 -0
  22. data/lib/lightspeed/images.rb +18 -0
  23. data/lib/lightspeed/inventories.rb +10 -0
  24. data/lib/lightspeed/inventory.rb +14 -0
  25. data/lib/lightspeed/item.rb +55 -18
  26. data/lib/lightspeed/item_attribute_set.rb +15 -0
  27. data/lib/lightspeed/item_attribute_sets.rb +10 -0
  28. data/lib/lightspeed/item_matrices.rb +6 -3
  29. data/lib/lightspeed/item_matrix.rb +50 -10
  30. data/lib/lightspeed/items.rb +6 -7
  31. data/lib/lightspeed/order.rb +36 -0
  32. data/lib/lightspeed/orders.rb +12 -0
  33. data/lib/lightspeed/price_level.rb +16 -0
  34. data/lib/lightspeed/price_levels.rb +10 -0
  35. data/lib/lightspeed/prices.rb +45 -0
  36. data/lib/lightspeed/request.rb +98 -29
  37. data/lib/lightspeed/request_throttler.rb +33 -0
  38. data/lib/lightspeed/resource.rb +221 -0
  39. data/lib/lightspeed/sale.rb +59 -0
  40. data/lib/lightspeed/sale_line.rb +54 -0
  41. data/lib/lightspeed/sale_lines.rb +11 -0
  42. data/lib/lightspeed/sales.rb +12 -0
  43. data/lib/lightspeed/shop.rb +32 -0
  44. data/lib/lightspeed/shops.rb +10 -0
  45. data/lib/lightspeed/special_order.rb +24 -0
  46. data/lib/lightspeed/special_orders.rb +12 -0
  47. data/lib/lightspeed/vendor.rb +25 -0
  48. data/lib/lightspeed/vendors.rb +11 -0
  49. data/lib/lightspeed/version.rb +3 -1
  50. data/lib/lightspeed_pos.rb +5 -5
  51. data/lightspeed_pos.gemspec +11 -7
  52. data/script/buildkite +24 -0
  53. data/script/docker_tests +29 -0
  54. metadata +96 -38
  55. data/lib/lightspeed/account_resources.rb +0 -103
  56. data/lib/lightspeed/base.rb +0 -17
  57. data/lib/lightspeed/errors.rb +0 -8
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'collection'
4
+
5
+ require_relative 'order'
6
+
7
+ module Lightspeed
8
+ class Orders < Lightspeed::Collection
9
+ alias_method :archive, :destroy
10
+ end
11
+ end
12
+
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource'
4
+
5
+ module Lightspeed
6
+ class PriceLevel < Lightspeed::Resource
7
+ fields(
8
+ priceLevelID: :id,
9
+ name: :string,
10
+ archived: :boolean,
11
+ canBeArchived: :boolean,
12
+ type: :string,
13
+ Calculation: :String
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'collection'
4
+
5
+ require_relative 'price_level'
6
+
7
+ module Lightspeed
8
+ class PriceLevels < Lightspeed::Collection
9
+ end
10
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'bigdecimal'
5
+
6
+ module Lightspeed
7
+ class Prices
8
+ def initialize(attributes)
9
+ @attributes = attributes
10
+ end
11
+
12
+ def prices
13
+ @prices ||= @attributes["ItemPrice"].map { |v| [v["useType"].parameterize.underscore.to_sym, BigDecimal(v["amount"])] }.to_h
14
+ end
15
+
16
+ def as_json
17
+ attributes
18
+ end
19
+ alias_method :to_h, :as_json
20
+
21
+ def to_json
22
+ Yajl::Encoder.encode(as_json)
23
+ end
24
+
25
+ def [](key)
26
+ prices[key]
27
+ end
28
+
29
+ def inspect
30
+ prices.inspect
31
+ end
32
+
33
+ def respond_to?(method, private_method)
34
+ prices.keys.include?(method) || super
35
+ end
36
+
37
+ def method_missing(method, *args, &block)
38
+ if prices.keys.include?(method)
39
+ prices[method]
40
+ else
41
+ super
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,55 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+ require 'net/http'
5
+
1
6
  module Lightspeed
2
7
  class Request
3
- attr_accessor :raw_request
8
+ attr_accessor :raw_request, :bucket_max, :bucket_level
9
+
10
+ SECONDS_TO_WAIT_WHEN_THROTTLED = 60 # API requirements.
11
+
12
+ class << self
13
+ attr_writer :verbose
14
+ end
15
+
16
+ def self.verbose?
17
+ !! @verbose
18
+ end
4
19
 
5
- def self.base_url
6
- "https://api.merchantos.com/API"
20
+ def self.base_host
21
+ "api.merchantos.com"
22
+ end
23
+
24
+ def self.base_path
25
+ "/API"
7
26
  end
8
27
 
9
28
  def initialize(client, method:, path:, params: nil, body: nil)
10
- @raw_request = Typhoeus::Request.new(
11
- self.class.base_url + path,
12
- method: method,
13
- body: body,
14
- params: params
15
- )
16
-
17
- if client.oauth_token
18
- @raw_request.options[:headers].merge!(
19
- "Authorization" => "OAuth #{client.oauth_token}"
20
- )
21
- end
29
+ @method = method
30
+ @params = params
31
+ @path = path
32
+ @bucket_max = Float::INFINITY
33
+ @bucket_level = 0
34
+ @http = Net::HTTP.new(self.class.base_host, 443)
35
+ @http.use_ssl = true
36
+ @raw_request = request_class.new(uri)
37
+ @raw_request.body = body if body
38
+ @raw_request.set_form_data(@params) if @params && @method != :get
39
+ @client = client
40
+ set_authorization_header
41
+ end
22
42
 
23
- @raw_request.options[:userpwd] = "#{client.api_key}:apikey" if client.api_key
43
+ def set_authorization_header
44
+ @raw_request["Authorization"] = "Bearer #{@client.oauth_token}" if @client.oauth_token
24
45
  end
25
46
 
26
- def perform
27
- response = raw_request.run
28
- if response.code == 200
47
+ def perform_raw
48
+ response = @http.request(raw_request)
49
+ extract_rate_limits(response)
50
+ if response.code == "200"
29
51
  handle_success(response)
30
52
  else
31
53
  handle_error(response)
32
54
  end
33
55
  end
34
56
 
57
+ def perform
58
+ perform_raw
59
+ rescue Lightspeed::Error::Throttled
60
+ retry_throttled_request
61
+ rescue Lightspeed::Error::Unauthorized => e
62
+ raise e if @attempted_oauth_token_refresh
63
+ @client.refresh_oauth_token
64
+ set_authorization_header
65
+ @attempted_oauth_token_refresh = true
66
+ perform
67
+ end
68
+
35
69
  private
36
70
 
37
71
  def handle_success(response)
38
- JSON.parse(response.body)
72
+ json = Yajl::Parser.parse(response.body)
73
+ pp json if self.class.verbose?
74
+ json
75
+ end
76
+
77
+ def retry_throttled_request
78
+ puts 'retrying throttled request after 60s.' if self.class.verbose?
79
+ sleep SECONDS_TO_WAIT_WHEN_THROTTLED
80
+ perform
39
81
  end
40
82
 
41
83
  def handle_error(response)
42
- data = JSON.parse(response.body)
43
- error = case response.code
44
- when 400
45
- Lightspeed::Errors::BadRequest
46
- when 401
47
- Lightspeed::Errors::Unauthorized
48
- when 500
49
- Lightspeed::Errors::InternalServerError
84
+ error = case response.code.to_s
85
+ when '400' then Lightspeed::Error::BadRequest
86
+ when '401' then Lightspeed::Error::Unauthorized
87
+ when '403' then Lightspeed::Error::NotAuthorized
88
+ when '404' then Lightspeed::Error::NotFound
89
+ when '429' then Lightspeed::Error::Throttled
90
+ when /5../ then Lightspeed::Error::InternalServerError
91
+ else Lightspeed::Error
50
92
  end
51
93
 
52
- raise error.new(data["message"]) if error # rubocop:disable RaiseArgs
94
+ # We may not get back valid JSON for a failed request
95
+ begin
96
+ data = Yajl::Parser.parse(response.body)
97
+ raise error, data["message"]
98
+ rescue Yajl::ParseError
99
+ raise error, response.code
100
+ end
101
+ end
102
+
103
+ def extract_rate_limits(response)
104
+ if bucket_headers = response["X-LS-API-Bucket-Level"]
105
+ @bucket_level, @bucket_max = bucket_headers.split("/").map(&:to_f)
106
+ end
107
+ end
108
+
109
+ def uri
110
+ uri = self.class.base_path + @path
111
+ uri += "?#{URI.encode_www_form(@params)}" if @params && @method == :get
112
+ uri
113
+ end
114
+
115
+ def request_class
116
+ case @method
117
+ when :get then Net::HTTP::Get
118
+ when :put then Net::HTTP::Put
119
+ when :post then Net::HTTP::Post
120
+ when :delete then Net::HTTP::Delete
121
+ end
53
122
  end
54
123
  end
55
124
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightspeed
4
+ class RequestThrottler
5
+ attr_accessor :bucket_level, :bucket_max, :units_per_second
6
+
7
+ def initialize
8
+ @units_per_second = 0.5
9
+ @bucket_max = Float::INFINITY
10
+ @bucket_level = 0
11
+ end
12
+
13
+ def perform_request request
14
+ u = units request
15
+ sleep(u / @units_per_second) if @bucket_level + u > @bucket_max
16
+ response = request.perform
17
+ extract_rate_limits request
18
+ response
19
+ end
20
+
21
+ private
22
+
23
+ def units request
24
+ if request.raw_request.is_a? Net::HTTP::Get then 1 else 10 end
25
+ end
26
+
27
+ def extract_rate_limits request
28
+ @bucket_max, @bucket_level = request.bucket_max, request.bucket_level
29
+ @units_per_second = @bucket_max/60.0
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'active_support/core_ext/string'
5
+ require 'active_support/core_ext/hash/slice'
6
+
7
+ require_relative 'collection'
8
+
9
+ module Lightspeed
10
+ class ID < Integer; end
11
+ class Link; end # rubocop:disable Lint/EmptyClass
12
+ class Resource
13
+ attr_accessor :id, :attributes, :client, :context, :account
14
+
15
+ def initialize(client: nil, context: nil, attributes: {})
16
+ self.client = client
17
+ self.context = context
18
+ self.attributes = attributes
19
+ end
20
+
21
+ def attributes=(attributes)
22
+ @attributes = attributes
23
+ attributes.each do |key, value|
24
+ send(:"#{key}=", value) if self.respond_to?(:"#{key}=")
25
+ end
26
+ self.id = send(self.class.id_field)
27
+ attributes
28
+ end
29
+
30
+ def account
31
+ @account || context.try(:account)
32
+ end
33
+
34
+ def self.fields(fields = {})
35
+ @fields ||= []
36
+ attr_writer(*fields.keys)
37
+
38
+ fields.each do |name, klass|
39
+ @fields << define_method(name) do
40
+ get_transformed_value(name, klass)
41
+ end
42
+ end
43
+ @fields
44
+ end
45
+
46
+ def get_transformed_value(name, kind)
47
+ value = instance_variable_get("@#{name}")
48
+ if value.is_a?(String)
49
+ case kind
50
+ when :string then value
51
+ when :id, :integer then value.to_i
52
+ when :datetime then DateTime.parse(value)
53
+ when :boolean then value == 'true'
54
+ when :decimal then BigDecimal(value)
55
+ when :hash then Hash.new(value)
56
+ else
57
+ raise ArgumentError, "Could not transform value #{value} to a #{kind}"
58
+ end
59
+ else
60
+ value
61
+ end
62
+ end
63
+
64
+ def client
65
+ @client || context.try(:client)
66
+ end
67
+
68
+ def load
69
+ self.attributes = get[resource_name] if (attributes.keys - [self.class.id_field]).empty?
70
+ end
71
+
72
+ def reload
73
+ self.attributes = get[resource_name]
74
+ end
75
+
76
+ def update(attributes = {})
77
+ self.attributes = put(body: Yajl::Encoder.encode(attributes))[resource_name]
78
+ end
79
+
80
+ def destroy
81
+ self.attributes = delete[resource_name]
82
+ self
83
+ end
84
+
85
+ def self.resource_name
86
+ name.demodulize
87
+ end
88
+
89
+ def self.collection_name
90
+ resource_name.pluralize
91
+ end
92
+
93
+ def self.id_field
94
+ "#{resource_name.camelize(:lower)}ID"
95
+ end
96
+
97
+ def id_params
98
+ { self.class.id_field => id }
99
+ end
100
+
101
+ def self.relationships(*args)
102
+ @relationships ||= []
103
+ paired_args = args.flat_map { |r| r.is_a?(Hash) ? r.to_a : [[r, r]] }
104
+ paired_args.each do |(relation_name, class_name)|
105
+ method_name = relation_name.to_s.underscore.to_sym
106
+ @relationships << define_method(method_name) do
107
+ instance_variable_get("@#{method_name}") || get_relation(method_name, relation_name, class_name)
108
+ end
109
+ end
110
+ @relationships
111
+ end
112
+
113
+ def inspect
114
+ "#<#{self.class.name} API#{base_path}>"
115
+ end
116
+
117
+
118
+ def to_json
119
+ Yajl::Encoder.encode(as_json)
120
+ end
121
+
122
+ def as_json
123
+ fields_to_h.merge(relationships_to_h).reject { |_, v| v.nil? || v == {} }
124
+ end
125
+ alias_method :to_h, :as_json
126
+
127
+ def base_path
128
+ if context.is_a?(Lightspeed::Collection)
129
+ "#{context.base_path}/#{id}"
130
+ else
131
+ "#{account.base_path}/#{resource_name}/#{id}"
132
+ end
133
+ end
134
+
135
+ def singular_path_parent
136
+ context
137
+ end
138
+
139
+ def resource_name
140
+ self.class.resource_name
141
+ end
142
+
143
+ def read_attribute_for_serialization(method_name)
144
+ method_name = method_name.to_sym
145
+
146
+ if self.class.fields.include?(method_name) || self.class.relationships.include?(method_name)
147
+ send(method_name)
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def fields_to_h
154
+ self.class.fields.map { |f| [f, send(f)] }.to_h
155
+ end
156
+
157
+ def relationships_to_h
158
+ self.class.relationships.map { |r| [r.to_s.camelize, send(r).to_h] }.to_h
159
+ end
160
+
161
+ def collection_class
162
+ "Lightspeed::#{self.class.collection_name}".constantize
163
+ end
164
+
165
+ def get_relation(method_name, relation_name, class_name)
166
+ klass = "Lightspeed::#{class_name}".constantize
167
+ case
168
+ when klass <= Lightspeed::Collection then get_collection_relation(method_name, relation_name, klass)
169
+ when klass <= Lightspeed::Resource then get_resource_relation(method_name, relation_name, klass)
170
+ end
171
+ end
172
+
173
+ def get_collection_relation(method_name, relation_name, klass)
174
+ collection = klass.new(context: self, attributes: attributes[relation_name.to_s])
175
+ instance_variable_set("@#{method_name}", collection)
176
+ end
177
+
178
+ def get_resource_relation(method_name, relation_name, klass)
179
+ id_field = "#{relation_name.to_s.camelize(:lower)}ID" # parentID != #categoryID, so we can't use klass.id_field
180
+ resource = if send(id_field).to_i.nonzero?
181
+ rel_attributes = attributes[klass.resource_name] || { klass.id_field => send(id_field) }
182
+ klass.new(context: self, attributes: rel_attributes).tap(&:load)
183
+ end
184
+ instance_variable_set("@#{method_name}", resource)
185
+ end
186
+
187
+ def context_params
188
+ if context.respond_to?(:id_field) &&
189
+ respond_to?(context.id_field.to_sym)
190
+ { context.id_field => context.id }
191
+ else
192
+ {}
193
+ end
194
+ end
195
+
196
+ def get(params: {})
197
+ params = { load_relations: 'all' }.merge(context_params).merge(params)
198
+ client.get(
199
+ path: resource_path,
200
+ params: params
201
+ )
202
+ end
203
+
204
+ def put(body:)
205
+ client.put(
206
+ path: resource_path,
207
+ body: body
208
+ )
209
+ end
210
+
211
+ def delete
212
+ client.delete(
213
+ path: resource_path
214
+ )
215
+ end
216
+
217
+ def resource_path
218
+ "#{base_path}.json"
219
+ end
220
+ end
221
+ end