jbr 3.2.0 → 3.4.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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +48 -0
- data/lib/jbr/includable.rb +3 -2
- data/lib/jbr/itemized.rb +9 -0
- data/lib/jbr/job.rb +8 -1
- data/lib/jbr/line_item.rb +41 -0
- data/lib/jbr/mock/job.rb +2 -0
- data/lib/jbr/mock/line_item.rb +12 -0
- data/lib/jbr/oauth.rb +2 -9
- data/lib/jbr/refused.rb +5 -0
- data/lib/jbr/token.rb +35 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +11 -2
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc29abcfab78a836ad4c098926a10986bf90ec1d53a62ddcce2c6e1da47e9772
|
|
4
|
+
data.tar.gz: 4a807c61b3173205f6e53c47f71bbee28fceaa21930c7f2cb0797663c1e53920
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5cabf6ed4fe94c9d71205788acf0fc0464d87fcea88e316c02acf99d509b891df9e24b912ce525053741388591e704106911ec19c3504d30121b01b22a9bcc5e
|
|
7
|
+
data.tar.gz: 23b0588c84c8042d2b40d396ca34a7c1c34f77d20b566685cdc4ca5f1333d9482ae6779eb310de25f370449b6826b6e1331bc458e58e57e5d4a652dc03442696
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## [3.4.0] - 2026-08-17
|
|
2
|
+
|
|
3
|
+
- [New] `line_items` on a job: what the work actually was, where the title is only what
|
|
4
|
+
somebody called it. Each is a `Jbr::LineItem` answering `quantity`, `name` and
|
|
5
|
+
`description`, and reading as `3 Bathroom Faucet Installation (Professional installation
|
|
6
|
+
of a new bathroom faucet)` — without the parenthesis where nobody described it, and as the
|
|
7
|
+
name alone where Jobber holds no quantity either. `quantified` is the first half of that on
|
|
8
|
+
its own, how many of what. A quantity reads whole where Jobber's own Float has nothing
|
|
9
|
+
after the point and keeps its fraction where it has, so a line is `3 Faucets` or `3.5
|
|
10
|
+
Hours` as billed. Every line Jobber holds is answered, in the order it holds them
|
|
11
|
+
- [New] `includes(:line_items)`, which is how they are asked for. Nothing nested arrives
|
|
12
|
+
unasked, so the import walks that never read a line item pay nothing for them
|
|
13
|
+
- [New] `summary` on a job: its lines as a sentence of how many of what, `3 Bathroom Faucet
|
|
14
|
+
Installation and 2 Change Toilet Valve`. What the work was, where `title` is only what
|
|
15
|
+
somebody called it — and `name` again where the job has no lines, or where the query never
|
|
16
|
+
asked for them, so it is never nil and never empty
|
|
17
|
+
|
|
18
|
+
## [3.3.0] - 2026-08-13
|
|
19
|
+
|
|
20
|
+
- [Fix] Credentials are given up only when Jobber says the grant itself is no good. Any
|
|
21
|
+
refusal at all used to set `invalid_at` -- a 500, a rate limit, an unreadable body -- so
|
|
22
|
+
a moment of trouble at Jobber's end read as a dead token, and an app acting on that could
|
|
23
|
+
revoke one that still worked. Only an `invalid_grant` Jobber names counts now; everything
|
|
24
|
+
else raises `Jbr::Error` for the caller to retry, which is what trouble deserves
|
|
25
|
+
- [New] `Jbr::Refused`, a `Jbr::Error` for the grant being no good rather than for the
|
|
26
|
+
answer failing to arrive. Rescue it to tell the two apart
|
|
27
|
+
- [Change] The token endpoint moved to `Jbr::Token`. `Jbr::OAuth.post` still answers it
|
|
28
|
+
unchanged, and so do `Jbr::OAuth.client_id` and `Jbr::OAuth.client_secret`
|
|
29
|
+
|
|
1
30
|
## [3.2.0] - 2026-08-13
|
|
2
31
|
|
|
3
32
|
- [New] Wait rather than be refused. Jobber holds an app to two limits at once -- 2,500
|
data/README.md
CHANGED
|
@@ -41,6 +41,16 @@ Revoke credentials:
|
|
|
41
41
|
oauth.delete
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Credentials go bad only when Jobber says so. A refused refresh — the `invalid_grant` Jobber
|
|
45
|
+
names — sets `invalid_at` and answers queries with nothing. Anything else that goes wrong,
|
|
46
|
+
including a 500 or a rate limit, raises `Jbr::Error` instead, because a token that may still
|
|
47
|
+
work is worth more than a tidy failure:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
oauth.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
|
|
51
|
+
oauth.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jobber had trouble
|
|
52
|
+
```
|
|
53
|
+
|
|
44
54
|
### Requests
|
|
45
55
|
|
|
46
56
|
Create a Jobber request, finding or creating a Client with a matching phone number:
|
|
@@ -94,6 +104,32 @@ job.quote_total # => 240.0
|
|
|
94
104
|
job.created_at # => 2026-05-10 09:15:00
|
|
95
105
|
```
|
|
96
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, in the order it holds them and whatever each is
|
|
130
|
+
quantified at. One it holds no quantity for reads as its name alone. Ask for nothing and
|
|
131
|
+
nothing arrives, so `oauth.jobs.first.line_items` is empty where the query never named them.
|
|
132
|
+
|
|
97
133
|
### Invoices
|
|
98
134
|
|
|
99
135
|
Fetch a non-draft invoice from Jobber:
|
|
@@ -239,6 +275,18 @@ Jbr.mock.jobs = [ { id: 'job-01', title: 'Furnace tune-up', status: 'archived',
|
|
|
239
275
|
client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
|
|
240
276
|
```
|
|
241
277
|
|
|
278
|
+
Mock the lines a job is made of, under the job that is made of them:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
Jbr.mock.jobs = [ { id: 'job-01', line_items: [
|
|
282
|
+
{ quantity: 3.0, name: 'Bathroom Faucet Installation',
|
|
283
|
+
description: 'Professional installation of a new bathroom faucet' },
|
|
284
|
+
{ quantity: 2.0, name: 'Change Toilet Valve' } ] } ]
|
|
285
|
+
|
|
286
|
+
oauth.jobs.past.first.summary
|
|
287
|
+
# => '3 Bathroom Faucet Installation and 2 Change Toilet Valve'
|
|
288
|
+
```
|
|
289
|
+
|
|
242
290
|
### Visits
|
|
243
291
|
|
|
244
292
|
Mock the visits the account has:
|
data/lib/jbr/includable.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/jbr/itemized.rb
ADDED
|
@@ -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
|
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
# What to ask for wherever a record lists the lines it is made of.
|
|
9
|
+
SELECTION = "lineItems { nodes { #{FIELDS.join ' '} } }"
|
|
10
|
+
|
|
11
|
+
# @param nodes [Array<Hash>, nil] the lines as Jobber answered them, if it answered any.
|
|
12
|
+
# @return [Array<LineItem>] one per line, in the order Jobber holds them.
|
|
13
|
+
def self.from(nodes) = nodes.to_a.map { |node| new node: node }
|
|
14
|
+
|
|
15
|
+
# @return [Integer, Float, nil] how many of it the job is for.
|
|
16
|
+
def quantity = whole @node['quantity']
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] what the line is called.
|
|
19
|
+
def name = @node['name']
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] what doing it involves, in whoever wrote the line's own words.
|
|
22
|
+
def description = @node['description']
|
|
23
|
+
|
|
24
|
+
# @return [String] how many of what: `3 Bathroom Faucet Installation`, and the name alone
|
|
25
|
+
# where Jobber holds no quantity for the line.
|
|
26
|
+
def quantified = [ quantity, name ].compact.join ' '
|
|
27
|
+
|
|
28
|
+
# @return [String] how a line reads: `3 Faucet install (Fits a new faucet)`, and without
|
|
29
|
+
# the parenthesis where nobody described it.
|
|
30
|
+
def to_s = [ quantified, described ].compact.join ' '
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Jobber answers every quantity as a Float, and a whole one reads as an Integer:
|
|
35
|
+
# `3 Faucets` rather than `3.0 Faucets`. A fraction keeps its point — `3.5 Faucets` —
|
|
36
|
+
# since rounding it would lie about what was billed.
|
|
37
|
+
def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
|
|
38
|
+
|
|
39
|
+
def described = ("(#{description})" if description.present?)
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/jbr/mock/job.rb
CHANGED
|
@@ -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
|
@@ -66,14 +66,7 @@ module Jbr
|
|
|
66
66
|
|
|
67
67
|
# Exchange a code or a refresh token for credentials.
|
|
68
68
|
def self.post(params = {})
|
|
69
|
-
|
|
70
|
-
response = Net::HTTP.post_form uri,
|
|
71
|
-
params.merge(client_id: client_id, client_secret: client_secret)
|
|
72
|
-
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
73
|
-
output = JSON.parse(response.body)
|
|
74
|
-
{ access_token: output['access_token'], refresh_token: output['refresh_token'],
|
|
75
|
-
expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
|
|
76
|
-
}
|
|
69
|
+
Token.post params.merge(client_id: client_id, client_secret: client_secret)
|
|
77
70
|
end
|
|
78
71
|
|
|
79
72
|
private
|
|
@@ -85,7 +78,7 @@ module Jbr
|
|
|
85
78
|
@access_token = output[:access_token]
|
|
86
79
|
@refresh_token = output[:refresh_token]
|
|
87
80
|
@expires_at = output[:expires_at]
|
|
88
|
-
rescue
|
|
81
|
+
rescue Refused
|
|
89
82
|
@invalid_at = Time.now
|
|
90
83
|
false
|
|
91
84
|
end
|
data/lib/jbr/refused.rb
ADDED
data/lib/jbr/token.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The endpoint that trades an authorization code or a refresh token for credentials.
|
|
3
|
+
class Token
|
|
4
|
+
# Where a grant is exchanged.
|
|
5
|
+
URL = 'https://api.getjobber.com/api/oauth/token'
|
|
6
|
+
|
|
7
|
+
# What Jobber calls a grant that is no good, in the OAuth 2 word for it.
|
|
8
|
+
REFUSAL = 'invalid_grant'
|
|
9
|
+
|
|
10
|
+
# Trade a grant for credentials.
|
|
11
|
+
# @param params [Hash] the grant, and the app making the exchange.
|
|
12
|
+
# @raise [Refused] where Jobber says the grant itself is no good.
|
|
13
|
+
# @raise [Error] where Jobber could not answer about it.
|
|
14
|
+
# @return [Hash] the tokens, and the moment the access one expires.
|
|
15
|
+
def self.post(params = {})
|
|
16
|
+
response = Net::HTTP.post_form URI(URL), params
|
|
17
|
+
raise Refused, response.body if refused? response
|
|
18
|
+
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
19
|
+
|
|
20
|
+
output = JSON.parse response.body
|
|
21
|
+
{ access_token: output['access_token'], refresh_token: output['refresh_token'],
|
|
22
|
+
expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.refused?(response)
|
|
27
|
+
return false if response.is_a? Net::HTTPSuccess
|
|
28
|
+
|
|
29
|
+
JSON.parse(response.body)['error'] == REFUSAL
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
private_class_method :refused?
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/jbr/version.rb
CHANGED
data/lib/jbr.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'net/http'
|
|
3
3
|
|
|
4
|
-
# Only the
|
|
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'
|
|
@@ -15,6 +17,8 @@ require 'jbr/mock'
|
|
|
15
17
|
|
|
16
18
|
require 'jbr/url'
|
|
17
19
|
require 'jbr/error'
|
|
20
|
+
require 'jbr/refused'
|
|
21
|
+
require 'jbr/token'
|
|
18
22
|
# Phone before Cliental, and Cliental before the records that include it: what each asks
|
|
19
23
|
# Jobber for about a client is built as they load.
|
|
20
24
|
require 'jbr/phone'
|
|
@@ -30,6 +34,10 @@ require 'jbr/account'
|
|
|
30
34
|
require 'jbr/property'
|
|
31
35
|
require 'jbr/properted'
|
|
32
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'
|
|
33
41
|
require 'jbr/client'
|
|
34
42
|
require 'jbr/invoice'
|
|
35
43
|
require 'jbr/job'
|
|
@@ -42,6 +50,7 @@ require 'jbr/mock/oauth'
|
|
|
42
50
|
require 'jbr/mock/client'
|
|
43
51
|
require 'jbr/mock/property'
|
|
44
52
|
require 'jbr/mock/quote'
|
|
53
|
+
require 'jbr/mock/line_item'
|
|
45
54
|
require 'jbr/mock/job'
|
|
46
55
|
require 'jbr/mock/jobs'
|
|
47
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.
|
|
4
|
+
version: 3.4.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
|
|
@@ -137,9 +140,11 @@ files:
|
|
|
137
140
|
- lib/jbr/properted.rb
|
|
138
141
|
- lib/jbr/property.rb
|
|
139
142
|
- lib/jbr/quote.rb
|
|
143
|
+
- lib/jbr/refused.rb
|
|
140
144
|
- lib/jbr/request.rb
|
|
141
145
|
- lib/jbr/resource.rb
|
|
142
146
|
- lib/jbr/throttle.rb
|
|
147
|
+
- lib/jbr/token.rb
|
|
143
148
|
- lib/jbr/url.rb
|
|
144
149
|
- lib/jbr/version.rb
|
|
145
150
|
- lib/jbr/visit.rb
|