jbr 3.3.0 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 074fba6db0300fd967935b1996f1791016e216aa49ef829e771f56340400af1f
4
- data.tar.gz: c8433c006174ab91d76db6a331268c325a90ff4f049f179c5adf46acd9716487
3
+ metadata.gz: 0f0002106d5864e3b0224d1a454f67813be100c2fca6abd411417f81ac913e53
4
+ data.tar.gz: 1e7998f2402eea55768b060ddc4057a7d513c29741b419fa5ade852caaf71a7a
5
5
  SHA512:
6
- metadata.gz: 1d3e116c317b973a9600af44e3fd02466e360321f08b4e1c696c2e214854b91b8318c45a8ffdb7c50f6f880bf07e6388bf060d33f54ad58b60b2ead27aab535d
7
- data.tar.gz: 28ee328df69a90a166cfaa4ed6ee0b22fad572c7bca8defb501afba0b3054c652b17741df883f4be32b0bd33ca7253a4a7e5872564b2f636f02d459323a7693c
6
+ metadata.gz: 6b7ce3628d2828f50173f6e41bccf9e6cb3dd2c9197dc7bcf0b30e814f49e5e27db25641835e0b8c174cd9c6e6e96daae74b16c3f532e67cf7fb6b45f466035c
7
+ data.tar.gz: 1cf960b5042097b943e62dd9ced8ab0e4adf0a084f8ca6143ea48b38c9e2510fae13b21d0576c9165916af7a85d587726d6ed34875c34a32bbe019c83632f662
data/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## [3.5.0] - 2026-08-17
2
+
3
+ - [Fix] A walk of jobs carrying their line items was refused outright: `Throttled`. Jobber
4
+ prices a query by the page it asks for and prices an *unbounded* connection at its own
5
+ maximum, so `lineItems` on a page of 40 jobs was charged as though every job carried the
6
+ largest job's worth of lines. The connection is bounded at 20 now, and a page of jobs is 20
7
+ rather than 40 — half the page is half the query, and a walk loses nothing by reading twice
8
+ as many. A job with more than 20 lines is summarized by its first 20
9
+ - [Fix] Jobber's own failures reach a caller as `Jbr::Error`, which is what this gem has
10
+ always said they would. `GraphQL::Error` was escaping instead, so an app that rescued
11
+ `Jbr::Error` — as the README tells it to — was not catching a throttle, a 500 or an
12
+ unreadable answer, and had its own job blow up rather than hearing that Jobber said no
13
+ - [Fix] A refusal for cost says what the cost was: `Throttled (cost 12400, 9500 of 10000
14
+ available, restoring 500/s)` rather than `Throttled`. Without the numbers there is no
15
+ telling a query too big to ever run from a bucket that needed another second
16
+ - [Change] A refused query prices the next one. Jobber reports the bucket when it says no as
17
+ readily as when it answers, and the throttle was only reading it on the way through — so a
18
+ walk that hit the ceiling then asked again immediately, at the same size, and was refused
19
+ again
20
+
21
+ ## [3.4.0] - 2026-08-17
22
+
23
+ - [New] `line_items` on a job: what the work actually was, where the title is only what
24
+ somebody called it. Each is a `Jbr::LineItem` answering `quantity`, `name` and
25
+ `description`, and reading as `3 Bathroom Faucet Installation (Professional installation
26
+ of a new bathroom faucet)` — without the parenthesis where nobody described it, and as the
27
+ name alone where Jobber holds no quantity either. `quantified` is the first half of that on
28
+ its own, how many of what. A quantity reads whole where Jobber's own Float has nothing
29
+ after the point and keeps its fraction where it has, so a line is `3 Faucets` or `3.5
30
+ Hours` as billed. Every line Jobber holds is answered, in the order it holds them
31
+ - [New] `includes(:line_items)`, which is how they are asked for. Nothing nested arrives
32
+ unasked, so the import walks that never read a line item pay nothing for them
33
+ - [New] `summary` on a job: its lines as a sentence of how many of what, `3 Bathroom Faucet
34
+ Installation and 2 Change Toilet Valve`. What the work was, where `title` is only what
35
+ somebody called it — and `name` again where the job has no lines, or where the query never
36
+ asked for them, so it is never nil and never empty
37
+
1
38
  ## [3.3.0] - 2026-08-13
2
39
 
3
40
  - [Fix] Credentials are given up only when Jobber says the grant itself is no good. Any
data/README.md CHANGED
@@ -104,6 +104,33 @@ job.quote_total # => 240.0
104
104
  job.created_at # => 2026-05-10 09:15:00
105
105
  ```
106
106
 
107
+ ### Line items
108
+
109
+ What the work actually was, where the title is only what somebody called it. Asked for the
110
+ same way as anything nested, since a page costs what it carries:
111
+
112
+ ```ruby
113
+ job = oauth.jobs.includes(:line_items).find 'Njc5MTk5'
114
+
115
+ job.summary # => '3 Bathroom Faucet Installation and 2 Change Toilet Valve', the lines as a
116
+ # sentence of how many of what. Falls back to #name where there are none
117
+
118
+ job.line_items # => an Array of the lines the job is made of
119
+ line = job.line_items.first
120
+ line.quantity # => 3, whole where Jobber's own Float has nothing after the point, and 3.5
121
+ # where it has: `3 Faucets`, or `3.5 Hours` for what was really billed
122
+ line.quantified # => '3 Bathroom Faucet Installation', how many of what
123
+ line.name # => 'Bathroom Faucet Installation'
124
+ line.description # => 'Professional installation of a new bathroom faucet'
125
+ line.to_s # => '3 Bathroom Faucet Installation (Professional installation of a new bathroom
126
+ # faucet)', and without the parenthesis where nobody described it
127
+ ```
128
+
129
+ Every line Jobber holds is in the list, up to twenty of them, in the order it holds them and
130
+ whatever each is quantified at. One it holds no quantity for reads as its name alone. Ask for
131
+ nothing and nothing arrives, so `oauth.jobs.first.line_items` is empty where the query never
132
+ named them.
133
+
107
134
  ### Invoices
108
135
 
109
136
  Fetch a non-draft invoice from Jobber:
@@ -179,6 +206,12 @@ done about either — every request waits for itself:
179
206
  A request that follows no other waits for nothing, so a single `find` is as quick as it ever
180
207
  was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.
181
208
 
209
+ Where Jobber refuses for cost anyway, the `Jbr::Error` raised says what the query would have
210
+ cost against what was available — `Throttled (cost 12400, 9500 of 10000 available, restoring
211
+ 500/s)` — so a query too big to ever run reads apart from a bucket that needed a moment. Every
212
+ connection this gem asks for is bounded, because Jobber prices an unbounded one at its own
213
+ maximum: twenty lines to a job, and twenty jobs or visits to a page.
214
+
182
215
  ### Events
183
216
 
184
217
  Parse the payload of a Jobber event webhook:
@@ -249,6 +282,18 @@ Jbr.mock.jobs = [ { id: 'job-01', title: 'Furnace tune-up', status: 'archived',
249
282
  client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
250
283
  ```
251
284
 
285
+ Mock the lines a job is made of, under the job that is made of them:
286
+
287
+ ```ruby
288
+ Jbr.mock.jobs = [ { id: 'job-01', line_items: [
289
+ { quantity: 3.0, name: 'Bathroom Faucet Installation',
290
+ description: 'Professional installation of a new bathroom faucet' },
291
+ { quantity: 2.0, name: 'Change Toilet Valve' } ] } ]
292
+
293
+ oauth.jobs.past.first.summary
294
+ # => '3 Bathroom Faucet Installation and 2 Change Toilet Valve'
295
+ ```
296
+
252
297
  ### Visits
253
298
 
254
299
  Mock the visits the account has:
@@ -23,13 +23,27 @@ module GraphQL
23
23
  raise Unauthorized, response.body if response.code == '401'
24
24
  raise Error, response.body unless response.is_a? Net::HTTPSuccess
25
25
  body = JSON.parse(response.body)
26
- errors = body['errors']
27
- raise Error, errors.map { |error| error['message'] }.join('; ') if errors.present?
26
+ # Before the refusal, not after it: an endpoint that reports what it will still answer
27
+ # reports it when it says no, which is when a caller most needs to know.
28
28
  yield body['extensions'] if block_given?
29
+ raise Error, refusal(body) if body['errors'].present?
29
30
  body.fetch('data')
30
31
  end
31
32
 
32
33
  private
34
+
35
+ # What the endpoint refused, and — where it priced the refusal — what the query would have
36
+ # cost against what was available. `Throttled` on its own leaves a caller unable to tell a
37
+ # query too big to ever run from a bucket that only needed a moment.
38
+ def refusal(body)
39
+ message = body['errors'].map { |error| error['message'] }.join '; '
40
+ cost = body['extensions'].to_h['cost'].to_h
41
+ status = cost['throttleStatus'].to_h
42
+ return message if status.empty?
43
+
44
+ "#{message} (cost #{cost['requestedQueryCost']}, #{status['currentlyAvailable']} of " \
45
+ "#{status['maximumAvailable']} available, restoring #{status['restoreRate']}/s)"
46
+ end
33
47
  def request_headers
34
48
  { 'Authorization' => "Bearer #{@token}", 'Content-Type' => 'application/json' }.merge @headers
35
49
  end
@@ -2,8 +2,8 @@ module Jbr
2
2
  # Extends a list of records with the chaining that says what to bring back beside them.
3
3
  # Nothing extra comes back unasked: a page costs what it carries.
4
4
  module Includable
5
- # @param names [Array<Symbol, Hash>] :client, :property, or property: :client for the
6
- # client whose file the place sits on.
5
+ # @param names [Array<Symbol, Hash>] :client, :line_items, :property, or
6
+ # property: :client for the client whose file the place sits on.
7
7
  # @return [Resource] the same list, asking Jobber for those too.
8
8
  def includes(*names)
9
9
  named = names.each_with_object({}) do |name, all|
@@ -20,6 +20,7 @@ module Jbr
20
20
  def selection_of(name, nested)
21
21
  case name
22
22
  when :client then Cliental::SELECTION
23
+ when :line_items then LineItem::SELECTION
23
24
  when :property then Properted.selection client: nested == :client
24
25
  end
25
26
  end
@@ -0,0 +1,9 @@
1
+ module Jbr
2
+ # Extends a record Jobber itemizes: a job, whose lines say what the work actually was
3
+ # where the title only says what somebody called it.
4
+ module Itemized
5
+ # @return [Array<LineItem>] the lines the record is made of, empty where the query never
6
+ # asked for them — a page costs what it carries, so nothing nested arrives unasked.
7
+ def line_items = LineItem.from @node.dig('lineItems', 'nodes')
8
+ end
9
+ end
data/lib/jbr/job.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Jbr
2
2
  # Work a Jobber user accepted and scheduled.
3
3
  class Job < Resource
4
- include Cliental, Named, Properted
4
+ include Cliental, Itemized, Named, Properted
5
5
 
6
6
  # @return [String, nil] what the job is called, where whoever opened it named it.
7
7
  def title = @node['title']
@@ -9,6 +9,13 @@ module Jbr
9
9
  # @return [String, nil] what the work is, in the words whoever opened the job wrote.
10
10
  def instructions = @node['instructions']
11
11
 
12
+ # What the job's lines add up to, each as how many of what: `3 Faucet install and 2 Valve
13
+ # change`. The lines say what the work was where a title only says what it was called, so
14
+ # this reads better than one — and falls back to {#name} where the job has no lines, or
15
+ # where the query never asked for them. Never nil and never empty.
16
+ # @return [String] the lines as a sentence, or the title, or the ID.
17
+ def summary = line_items.map(&:quantified).to_sentence.presence || name
18
+
12
19
  # @return [String, nil] where Jobber files the job in its own workflow.
13
20
  def status = @node['jobStatus']
14
21
 
data/lib/jbr/jobs.rb CHANGED
@@ -29,12 +29,14 @@ module Jbr
29
29
 
30
30
  private
31
31
 
32
- # Forty a page, not a hundred: Jobber prices a query by its page size and refuses the
33
- # wider one, and what an includes brings back is charged for on top.
32
+ # Twenty a page, not forty and not a hundred: Jobber prices a query by its page size, and
33
+ # what an includes brings back is charged for on top of every row of it — so a page of jobs
34
+ # carrying their lines, their property and its client priced past what a bucket holds. Half
35
+ # the page costs half the query and loses nothing, since a walk simply reads more pages.
34
36
  def page
35
37
  <<~GRAPHQL
36
38
  query($after: String, $filter: JobFilterAttributes) {
37
- jobs(first: 40, after: $after, filter: $filter) {
39
+ jobs(first: 20, after: $after, filter: $filter) {
38
40
  nodes { #{FIELDS} #{selections} }
39
41
  pageInfo { hasNextPage endCursor }
40
42
  }
@@ -0,0 +1,46 @@
1
+ module Jbr
2
+ # One line of the work a job is made of: how many of a thing, what it is called, and what
3
+ # doing it involves.
4
+ class LineItem < Resource
5
+ # What Jobber calls each field of a line.
6
+ FIELDS = %w[quantity name description]
7
+
8
+ # The most lines to read off one record. Bounded because Jobber prices a connection by the
9
+ # page it is asked for and prices an unbounded one at its own maximum, so the lines of a
10
+ # page of jobs were charged for as though every job had the largest job's worth of them.
11
+ PAGE = 20
12
+
13
+ # What to ask for wherever a record lists the lines it is made of.
14
+ SELECTION = "lineItems(first: #{PAGE}) { nodes { #{FIELDS.join ' '} } }"
15
+
16
+ # @param nodes [Array<Hash>, nil] the lines as Jobber answered them, if it answered any.
17
+ # @return [Array<LineItem>] one per line, in the order Jobber holds them.
18
+ def self.from(nodes) = nodes.to_a.map { |node| new node: node }
19
+
20
+ # @return [Integer, Float, nil] how many of it the job is for.
21
+ def quantity = whole @node['quantity']
22
+
23
+ # @return [String, nil] what the line is called.
24
+ def name = @node['name']
25
+
26
+ # @return [String, nil] what doing it involves, in whoever wrote the line's own words.
27
+ def description = @node['description']
28
+
29
+ # @return [String] how many of what: `3 Bathroom Faucet Installation`, and the name alone
30
+ # where Jobber holds no quantity for the line.
31
+ def quantified = [ quantity, name ].compact.join ' '
32
+
33
+ # @return [String] how a line reads: `3 Faucet install (Fits a new faucet)`, and without
34
+ # the parenthesis where nobody described it.
35
+ def to_s = [ quantified, described ].compact.join ' '
36
+
37
+ private
38
+
39
+ # Jobber answers every quantity as a Float, and a whole one reads as an Integer:
40
+ # `3 Faucets` rather than `3.0 Faucets`. A fraction keeps its point — `3.5 Faucets` —
41
+ # since rounding it would lie about what was billed.
42
+ def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
43
+
44
+ def described = ("(#{description})" if description.present?)
45
+ end
46
+ end
data/lib/jbr/mock/job.rb CHANGED
@@ -22,6 +22,8 @@ module Jbr
22
22
 
23
23
  def property = Mock::Property.new(node: @node.fetch(:property, {}))
24
24
 
25
+ def line_items = Mock::LineItem.from(@node.fetch(:line_items, []))
26
+
25
27
  def scheduled_at = @node[:scheduled_at]
26
28
 
27
29
  def completed_at = @node[:completed_at]
@@ -0,0 +1,12 @@
1
+ module Jbr
2
+ # A line of a job that reads from {Jbr.mock} instead of Jobber.
3
+ class Mock::LineItem < LineItem
4
+ # @return [Object, nil] the values the app asked for. The quantity is still read whole,
5
+ # so an app that mocks `3.0` of a thing sees the `3` a page would show.
6
+ def quantity = whole @node[:quantity]
7
+
8
+ def name = @node[:name]
9
+
10
+ def description = @node[:description]
11
+ end
12
+ end
data/lib/jbr/oauth.rb CHANGED
@@ -41,6 +41,11 @@ module Jbr
41
41
  client.query(statement, variables: variables) { |extensions| throttle.read extensions }
42
42
  rescue GraphQL::Unauthorized
43
43
  refresh ? retry : {}
44
+ rescue GraphQL::Error => error
45
+ # The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
46
+ # was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
47
+ # up instead of hearing that Jobber would not answer.
48
+ raise Error, error.message
44
49
  end
45
50
 
46
51
  # Delete a token. If the token is invalid, do nothing.
data/lib/jbr/throttle.rb CHANGED
@@ -21,7 +21,9 @@ module Jbr
21
21
  def read(extensions)
22
22
  cost = extensions.to_h['cost'].to_h
23
23
  status = cost['throttleStatus'].to_h
24
- @cost = cost['actualQueryCost'].to_f
24
+ # What a refused query would have cost is what it costs: Jobber prices it either way,
25
+ # and only an answered one reports an actual.
26
+ @cost = (cost['actualQueryCost'] || cost['requestedQueryCost']).to_f
25
27
  @available = status['currentlyAvailable'].to_f
26
28
  @restore_rate = status['restoreRate'].to_f
27
29
  end
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '3.3.0'
4
+ VERSION = '3.5.0'
5
5
  end
data/lib/jbr/visits.rb CHANGED
@@ -18,12 +18,12 @@ module Jbr
18
18
 
19
19
  private
20
20
 
21
- # Forty a page, not a hundred: Jobber prices a query by its page size and refuses the
22
- # wider one, and what an includes brings back is charged for on top.
21
+ # Twenty a page, the same as jobs: Jobber prices a query by its page size, and what an
22
+ # includes brings back is charged for on top of every row of it.
23
23
  def page
24
24
  <<~GRAPHQL
25
25
  query($after: String, $filter: VisitFilterAttributes) {
26
- visits(first: 40, after: $after, filter: $filter) {
26
+ visits(first: 20, after: $after, filter: $filter) {
27
27
  nodes { #{FIELDS} #{selections} }
28
28
  pageInfo { hasNextPage endCursor }
29
29
  }
data/lib/jbr.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  require 'json'
2
2
  require 'net/http'
3
3
 
4
- # Only the two Active Support files whose methods are used, rather than the whole of it:
4
+ # Only the three Active Support files whose methods are used, rather than the whole of it:
5
5
  # Jobber answers a field it holds nothing for with an empty string as readily as with null,
6
- # and a caller who validates presence needs those to arrive as the same nothing.
6
+ # and a caller who validates presence needs those to arrive as the same nothing. The third is
7
+ # for the sentence a job's lines read as.
7
8
  require 'active_support/core_ext/object/blank'
8
9
  require 'active_support/core_ext/enumerable'
10
+ require 'active_support/core_ext/array/conversions'
9
11
 
10
12
  require 'graphql/error'
11
13
  require 'graphql/unauthorized'
@@ -32,6 +34,10 @@ require 'jbr/account'
32
34
  require 'jbr/property'
33
35
  require 'jbr/properted'
34
36
  require 'jbr/includable'
37
+ # LineItem before Itemized, and both before Job: the lines a job is made of are asked for
38
+ # by a constant the include reads as it loads.
39
+ require 'jbr/line_item'
40
+ require 'jbr/itemized'
35
41
  require 'jbr/client'
36
42
  require 'jbr/invoice'
37
43
  require 'jbr/job'
@@ -44,6 +50,7 @@ require 'jbr/mock/oauth'
44
50
  require 'jbr/mock/client'
45
51
  require 'jbr/mock/property'
46
52
  require 'jbr/mock/quote'
53
+ require 'jbr/mock/line_item'
47
54
  require 'jbr/mock/job'
48
55
  require 'jbr/mock/jobs'
49
56
  require 'jbr/mock/invoice'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jbr
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -115,14 +115,17 @@ files:
115
115
  - lib/jbr/event.rb
116
116
  - lib/jbr/includable.rb
117
117
  - lib/jbr/invoice.rb
118
+ - lib/jbr/itemized.rb
118
119
  - lib/jbr/job.rb
119
120
  - lib/jbr/jobs.rb
121
+ - lib/jbr/line_item.rb
120
122
  - lib/jbr/mock.rb
121
123
  - lib/jbr/mock/account.rb
122
124
  - lib/jbr/mock/client.rb
123
125
  - lib/jbr/mock/invoice.rb
124
126
  - lib/jbr/mock/job.rb
125
127
  - lib/jbr/mock/jobs.rb
128
+ - lib/jbr/mock/line_item.rb
126
129
  - lib/jbr/mock/oauth.rb
127
130
  - lib/jbr/mock/property.rb
128
131
  - lib/jbr/mock/quote.rb