lightspeed_pos 0.1.0 → 0.6.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.
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