jbr 3.12.0 → 4.0.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/.rubocop.yml +5 -0
- data/CHANGELOG.md +59 -0
- data/README.md +155 -165
- data/lib/graphql/client.rb +2 -6
- data/lib/graphql/throttled.rb +0 -9
- data/lib/jbr/account.rb +44 -15
- data/lib/jbr/authorizing.rb +43 -0
- data/lib/jbr/collection.rb +14 -0
- data/lib/jbr/collections/customers.rb +54 -0
- data/lib/jbr/collections/invoices.rb +18 -0
- data/lib/jbr/{jobs.rb → collections/jobs.rb} +6 -6
- data/lib/jbr/collections/leads.rb +30 -0
- data/lib/jbr/collections/locations.rb +47 -0
- data/lib/jbr/collections/quotes.rb +18 -0
- data/lib/jbr/{visits.rb → collections/visits.rb} +8 -8
- data/lib/jbr/error.rb +1 -1
- data/lib/jbr/errors/throttled.rb +6 -0
- data/lib/jbr/includable.rb +6 -8
- data/lib/jbr/listable.rb +12 -34
- data/lib/jbr/mock/account.rb +25 -7
- data/lib/jbr/mock/invoice.rb +7 -11
- data/lib/jbr/mock/invoices.rb +7 -0
- data/lib/jbr/mock/jobs.rb +10 -6
- data/lib/jbr/mock/leads.rb +7 -0
- data/lib/jbr/mock/quote.rb +3 -8
- data/lib/jbr/mock/quotes.rb +7 -0
- data/lib/jbr/mock/visit.rb +3 -19
- data/lib/jbr/mock/visits.rb +9 -5
- data/lib/jbr/mock.rb +16 -3
- data/lib/jbr/phone.rb +5 -8
- data/lib/jbr/querying.rb +57 -0
- data/lib/jbr/reader.rb +9 -0
- data/lib/jbr/refreshing.rb +0 -8
- data/lib/jbr/resources/customer.rb +23 -0
- data/lib/jbr/resources/invoice.rb +29 -0
- data/lib/jbr/resources/job.rb +20 -0
- data/lib/jbr/resources/lead.rb +7 -0
- data/lib/jbr/resources/line.rb +15 -0
- data/lib/jbr/resources/location.rb +31 -0
- data/lib/jbr/resources/quote.rb +15 -0
- data/lib/jbr/resources/visit.rb +17 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +37 -46
- metadata +39 -28
- data/lib/jbr/client.rb +0 -98
- data/lib/jbr/cliental.rb +0 -14
- data/lib/jbr/invoice.rb +0 -43
- data/lib/jbr/itemized.rb +0 -9
- data/lib/jbr/job.rb +0 -40
- data/lib/jbr/line_item.rb +0 -36
- data/lib/jbr/mock/client.rb +0 -17
- data/lib/jbr/mock/job.rb +0 -31
- data/lib/jbr/mock/line_item.rb +0 -10
- data/lib/jbr/mock/oauth.rb +0 -22
- data/lib/jbr/mock/property.rb +0 -19
- data/lib/jbr/mock/request.rb +0 -12
- data/lib/jbr/mock/url.rb +0 -9
- data/lib/jbr/mocking.rb +0 -30
- data/lib/jbr/named.rb +0 -9
- data/lib/jbr/oauth.rb +0 -94
- data/lib/jbr/properted.rb +0 -17
- data/lib/jbr/property.rb +0 -93
- data/lib/jbr/quote.rb +0 -25
- data/lib/jbr/request.rb +0 -37
- data/lib/jbr/resource.rb +0 -26
- data/lib/jbr/retriable.rb +0 -20
- data/lib/jbr/url.rb +0 -16
- data/lib/jbr/visit.rb +0 -24
- /data/lib/jbr/{refused.rb → errors/refused.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 955de83498d3d68359199efa3e7b2d54bef89040000e80085969f4577e14d80d
|
|
4
|
+
data.tar.gz: 925c1785457c527ce3ad9dfe677a386327e70155f9895cccca6b51c278be758f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61ae3885b2ab939a53f5d27088957c7b230f55e6708440e603c8dafed5b369ef75e8430dcce5635d32962652f7bd3a6dea706f84f8ad7c46d45e865e242fff9a
|
|
7
|
+
data.tar.gz: 3945c08a563f06b43b3fd35f9aa579fa14ebeee8bc8468196a74f1cc4a38092dd429dbdfca118920f3a2f3a07e202ac9ac8a447409f9eeb042e826787d57abc8
|
data/.rubocop.yml
CHANGED
|
@@ -57,3 +57,8 @@ Layout/LineLength:
|
|
|
57
57
|
# lines per site, so the cop is declined and the rule held in review.
|
|
58
58
|
Style/MethodCallWithArgsParentheses:
|
|
59
59
|
Enabled: false
|
|
60
|
+
|
|
61
|
+
# The vocabulary names a keyword for every platform, and one a platform has no field for is
|
|
62
|
+
# taken and dropped rather than left out of the signature
|
|
63
|
+
Lint/UnusedMethodArgument:
|
|
64
|
+
AllowUnusedKeywordArguments: true
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [4.0.0] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
- [Breaking change] `Jbr::Account` is the gateway, and reads in the vocabulary the `company` gem
|
|
6
|
+
names. It replaces `Jbr::OAuth` and the `Jbr.oauth_for`, `Jbr.create_oauth` and
|
|
7
|
+
`Jbr.oauth_url_for` entry points: `Jbr::Account.new credentials`, `Jbr::Account.create code:,
|
|
8
|
+
redirect_uri:`, `Jbr::Account.url_for redirect_uri:, state:`, and `Jbr::Account.client_secret`.
|
|
9
|
+
`#account` is `#business`, answering a `Company::Business` whose `phone` is the ten digits to
|
|
10
|
+
dial rather than the string Jobber holds
|
|
11
|
+
- [Breaking change] `#requests` is `#leads`, and `leads.create` takes its keywords by name
|
|
12
|
+
as the vocabulary names them (`name:`, `surname:`, `phone:`, `email:`, `address:`,
|
|
13
|
+
`description:` for the title, `notes:` for the instructions, and a `source:` Jobber has no
|
|
14
|
+
field for) and
|
|
15
|
+
answers a `Jbr::Lead` with `id` and `customer` rather than the collection that filed it.
|
|
16
|
+
`Quote#request_id` is `#lead`, a `Jbr::Lead`; `Invoice#job_id` is `#job`, a `Jbr::Job`;
|
|
17
|
+
`Invoice#total`, `#issued_at` and `#completed_at` are `#amount`, a `BigDecimal`, and
|
|
18
|
+
`#fulfilled_at`, the moment the work was finished or the bill issued where the work never was
|
|
19
|
+
- [Breaking change] A job reads `#amount` as a `BigDecimal` where it read `#total` as a Float,
|
|
20
|
+
answers the quote it was won with as `#quote` -- a `Jbr::Quote` with `id` and `amount`,
|
|
21
|
+
nil where there was none -- in place of `#quote_id` and `#quote_total`, its title as
|
|
22
|
+
`#description` and its instructions as `#notes`. `#summary` is gone with `#name`: a caller sums a job up from its lines and
|
|
23
|
+
its description. `Job#title`, `#status` and `#client`, `Visit#title`, `#name`, `#job_id`
|
|
24
|
+
and `#client`, are gone, and `Visit#client_confirmed?` is `#confirmed?`, `#all_day?` is `#anytime?`
|
|
25
|
+
- [Breaking change] `includes` takes `:lines`, `:location` and `location: :customer` where it
|
|
26
|
+
took `:line_items`, `:property`, `:client` and `property: :client`, and only on jobs: a visit
|
|
27
|
+
happens where its job does, so it answers `#job` and no `#location` or `includes` of its own. A
|
|
28
|
+
job answers `#lines` and `#location`, and a location `#customer`. `Location#address` and `#state` are gone,
|
|
29
|
+
as are `Customer#first_name` and `#company_name`: `#name` answers the first name, or the
|
|
30
|
+
business's name where a person has none. `Jbr::LineItem`, `Jbr::Property`, `Jbr::Client` and
|
|
31
|
+
`Jbr::Request` are `Jbr::Line`, `Jbr::Location`, `Jbr::Customer` and `Jbr::Lead`
|
|
32
|
+
- [Breaking change] `Jbr.mock` takes `business` and `lead` where it took `account` and `request`,
|
|
33
|
+
and every slot reads by the vocabulary's keys: `description`, `amount`, `quote`,
|
|
34
|
+
`lines`, `location`, `customer`, `confirmed`, `lead`, `job`. A mock collection
|
|
35
|
+
answers the vocabulary's own kinds built from those hashes, so `Jbr::Mock::Job` and its
|
|
36
|
+
siblings are gone; `Jbr.mock = nil` hands the accounts back to Jobber
|
|
37
|
+
- [Breaking change] `Jbr::Retriable` is `Jbr::Throttled`, a `Company::Throttled` rather than a
|
|
38
|
+
`Jbr::Error`, so one rescue retries a refusal for rate from any platform; its `#cost`,
|
|
39
|
+
`#available`, `#maximum` and `#restore_rate` are gone and the numbers stay in the message.
|
|
40
|
+
`Jbr::Error` descends from `Company::Error`
|
|
41
|
+
- [Fix] An `includes` chained after `past` or `upcoming` keeps the window it was asked on. It
|
|
42
|
+
used to rebuild the list without the filter, so `account.jobs.past(1.year).includes(:lines)`
|
|
43
|
+
walked every job the account ever had
|
|
44
|
+
- [Fix] A mutation Jobber takes but will not act on raises `Jbr::Error` with the reasons Jobber
|
|
45
|
+
gave. It answers those with a 200 and the messages under the mutation's own `userErrors`,
|
|
46
|
+
which nothing read, so a client Jobber refused to open came back with a nil ID and the
|
|
47
|
+
request filed against nobody
|
|
48
|
+
- [Change] Every selection Jobber is asked for is generated from the keys the vocabulary reads
|
|
49
|
+
wherever Jobber's shape is flat, so a kind gaining a reader asks for it without a query
|
|
50
|
+
written by hand
|
|
51
|
+
|
|
52
|
+
## [3.13.0] - 2026-08-31
|
|
53
|
+
|
|
54
|
+
- [New] `Jbr::LineItem#id`, `#description` and `#amount`, beside the `#quantity` and `#name` a
|
|
55
|
+
line already answered. The ID is how an app tells a line it has seen before from a new one,
|
|
56
|
+
the description is what the line says beyond what it is called, and the amount is what
|
|
57
|
+
Jobber calls `totalPrice`. Every query that lists lines now asks for all five, and a mocked
|
|
58
|
+
line answers whatever a test names for each
|
|
59
|
+
|
|
1
60
|
## [3.12.0] - 2026-08-28
|
|
2
61
|
|
|
3
62
|
- [New] `Jbr::Account#name` and `#phone`, beside the `#id` it already answered. An app storing
|
data/README.md
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
# Jobber API Ruby client
|
|
2
2
|
|
|
3
|
-
A client for the Jobber GraphQL API
|
|
4
|
-
|
|
3
|
+
A client for the Jobber GraphQL API, answering in the vocabulary the
|
|
4
|
+
[company](https://github.com/claudiob/company) gem names: an account opens the business its
|
|
5
|
+
credentials belong to and the records the business holds, and each record reads by the same
|
|
6
|
+
names on Jobber as on any other platform.
|
|
7
|
+
|
|
8
|
+
## How to install
|
|
9
|
+
|
|
10
|
+
To install on your system, run
|
|
11
|
+
|
|
12
|
+
gem install jbr
|
|
13
|
+
|
|
14
|
+
To use inside a bundled Ruby project, add this line to the `Gemfile`:
|
|
15
|
+
|
|
16
|
+
gem 'jbr', '~> 4.0'
|
|
17
|
+
|
|
18
|
+
Semantic Versioning promises that `~> major.minor` never crosses a breaking change, so the pin
|
|
19
|
+
takes every 4.x release and stops short of 5.0.
|
|
5
20
|
|
|
6
21
|
## Available methods
|
|
7
22
|
|
|
@@ -10,28 +25,27 @@ Support, to tell a field Jobber answered empty from one it never answered at all
|
|
|
10
25
|
Generate the URL for Jobber users to authorize the app:
|
|
11
26
|
|
|
12
27
|
```ruby
|
|
13
|
-
url = Jbr.
|
|
28
|
+
url = Jbr::Account.url_for redirect_uri:, state:
|
|
14
29
|
url # => 'https://api.getjobber.com/api/oauth/authorize?state=...&redirect_uri=...'
|
|
15
30
|
```
|
|
16
31
|
|
|
17
|
-
Create
|
|
32
|
+
Create an account with the code Jobber sends back and the URI it sent it to:
|
|
18
33
|
|
|
19
34
|
```ruby
|
|
20
|
-
|
|
35
|
+
account = Jbr::Account.create code:, redirect_uri:
|
|
21
36
|
```
|
|
22
37
|
|
|
23
|
-
|
|
38
|
+
Open an account with credentials already on file:
|
|
24
39
|
|
|
25
40
|
```ruby
|
|
26
|
-
|
|
41
|
+
account = Jbr::Account.new access_token:, refresh_token:, expires_at:, account_id:
|
|
27
42
|
```
|
|
28
43
|
|
|
29
|
-
Where several processes hold copies of the same credentials
|
|
30
|
-
|
|
31
|
-
refresh token:
|
|
44
|
+
Where several processes hold copies of the same credentials, a queue of workers each building
|
|
45
|
+
its own, hand over a `store:` as well, and only one of them will ever spend the refresh token:
|
|
32
46
|
|
|
33
47
|
```ruby
|
|
34
|
-
|
|
48
|
+
account = Jbr::Account.new account_id:, invalid_at:, store: credentials
|
|
35
49
|
```
|
|
36
50
|
|
|
37
51
|
The store is anything answering two methods. `exclusively` takes whatever lock the app keeps
|
|
@@ -39,73 +53,82 @@ over those credentials and yields them *as they are right now*, read inside that
|
|
|
39
53
|
records the ones Jobber handed back:
|
|
40
54
|
|
|
41
55
|
```ruby
|
|
42
|
-
def exclusively = with_lock { yield
|
|
56
|
+
def exclusively = with_lock { yield tokens } # Active Record, in an app that has it
|
|
43
57
|
|
|
44
|
-
def write(
|
|
58
|
+
def write(account) = update tokens: { access_token: account.access_token, ... }
|
|
45
59
|
```
|
|
46
60
|
|
|
47
61
|
An expired access token is then refreshed once. Every other holder takes the lock, finds a
|
|
48
|
-
token that is no longer the one it tried, adopts it, and asks Jobber nothing
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
token that is no longer the one it tried, adopts it, and asks Jobber nothing. Without a store
|
|
63
|
+
each of them would spend a refresh token the first has already spent, and Jobber would call
|
|
64
|
+
every one of those a dead grant.
|
|
51
65
|
|
|
52
66
|
`exclusively` has to be exclusive against every **process** sharing the credentials, not just
|
|
53
67
|
every thread: a `Mutex` satisfies this interface and fixes nothing on a fleet of workers.
|
|
54
68
|
|
|
55
|
-
|
|
69
|
+
Read the credentials as Jobber last gave them:
|
|
56
70
|
|
|
57
71
|
```ruby
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
account.access_token # => 'eyJhbGciOiJIUzI1NiJ'
|
|
73
|
+
account.refresh_token # => 'ea02775958c5fca28d'
|
|
74
|
+
account.expires_at # => 2026-05-22 14:32:53
|
|
75
|
+
account.account_id # => 'Z2lkOi8vSm9iYmV'
|
|
62
76
|
```
|
|
63
77
|
|
|
64
|
-
Read the
|
|
78
|
+
Read the business they belong to:
|
|
65
79
|
|
|
66
80
|
```ruby
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
business = account.business
|
|
82
|
+
business.id # => 'Z2lkOi8vSm9iYmV'
|
|
83
|
+
business.name # => 'Acme Plumbing'
|
|
84
|
+
business.phone # => '7044597540', the ten digits to dial, or nil where none can be
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
Revoke credentials:
|
|
87
|
+
Revoke the credentials:
|
|
74
88
|
|
|
75
89
|
```ruby
|
|
76
|
-
|
|
90
|
+
account.delete
|
|
77
91
|
```
|
|
78
92
|
|
|
79
|
-
Credentials go bad only when Jobber says so of the grant itself
|
|
80
|
-
is not valid
|
|
93
|
+
Credentials go bad only when Jobber says so of the grant itself, `The provided refresh token
|
|
94
|
+
is not valid.`, which sets `invalid_at` and answers queries with nothing. Anything else that
|
|
81
95
|
goes wrong raises `Jbr::Error` instead: a 500, a rate limit, and the 401 Jobber answers an app
|
|
82
96
|
whose own client id and secret are wrong, which is every account's grant at once rather than
|
|
83
97
|
this one's:
|
|
84
98
|
|
|
85
99
|
```ruby
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
account.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
|
|
101
|
+
account.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jobber had trouble
|
|
88
102
|
```
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
The app's own client ID and secret are read from `JOBBER_CLIENT_ID` and `JOBBER_CLIENT_SECRET`
|
|
105
|
+
in the environment, and `Jbr::Account.client_secret` answers the one to check a webhook with.
|
|
106
|
+
|
|
107
|
+
### Leads
|
|
91
108
|
|
|
92
|
-
|
|
109
|
+
File a request on the account's board, against the client answering to the phone and the
|
|
110
|
+
property at the address, opening either where Jobber has none:
|
|
93
111
|
|
|
94
112
|
```ruby
|
|
95
|
-
|
|
96
|
-
email: 'jane@example.com',
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
lead = account.leads.create name: 'Jane', surname: 'Doe', phone: '5553335555',
|
|
114
|
+
email: 'jane@example.com', description: 'New Plumber Lead', notes: 'Needs new faucet',
|
|
115
|
+
source: nil, # Jobber has no source for a request, so this one is dropped
|
|
116
|
+
address: { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601' }
|
|
117
|
+
lead.id # => 'Z2lkOi8vSm9iYmVyL'
|
|
118
|
+
lead.customer.id # => 'MwMTU0Mg', the client the request was opened against
|
|
99
119
|
```
|
|
100
120
|
|
|
121
|
+
A mutation Jobber takes but will not act on, a phone it calls invalid or an email it already
|
|
122
|
+
holds, raises `Jbr::Error` with what Jobber said.
|
|
123
|
+
|
|
101
124
|
### Quotes
|
|
102
125
|
|
|
103
|
-
Fetch a quote from Jobber:
|
|
126
|
+
Fetch a quote from Jobber, and the lead it answers:
|
|
104
127
|
|
|
105
128
|
```ruby
|
|
106
|
-
quote =
|
|
129
|
+
quote = account.quotes.find 'Z2lkOi8vS'
|
|
107
130
|
quote.id # => 'Z2lkOi8vS'
|
|
108
|
-
quote.
|
|
131
|
+
quote.lead.id # => 'Z2lkOi8vSm9iYmVyL', the request the quote answers, or nil where none
|
|
109
132
|
```
|
|
110
133
|
|
|
111
134
|
### Jobs
|
|
@@ -113,60 +136,54 @@ quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
|
|
|
113
136
|
Fetch a job from Jobber by the ID it is filed under:
|
|
114
137
|
|
|
115
138
|
```ruby
|
|
116
|
-
job =
|
|
139
|
+
job = account.jobs.find 'Njc5MTk5'
|
|
117
140
|
job.id # => 'Z2lkOi8vS'
|
|
118
|
-
job.
|
|
141
|
+
job.quote.id # => 'Z2lkOi8vS', the quote the job was won with, or nil where none was
|
|
142
|
+
job.quote.amount # => 240.0, in dollars, as a BigDecimal
|
|
143
|
+
job.amount # => 260.0
|
|
144
|
+
job.notes # => 'Ring the doorbell twice', what Jobber calls the instructions
|
|
145
|
+
job.created_at # => 2026-05-10 09:15:00
|
|
119
146
|
job.scheduled_at # => 2026-05-14 23:02:52
|
|
120
147
|
job.completed_at # => 2026-05-18 11:36:13
|
|
121
148
|
```
|
|
122
149
|
|
|
123
150
|
Or walk the account's jobs, oldest first. Either half of the schedule takes how much of it
|
|
124
|
-
you meant
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
you meant, a duration measured from the same now the half is split at, and a walk that stops
|
|
152
|
+
at a boundary reads only the pages up to it. Jobber is asked for a page at a time, and only
|
|
153
|
+
once the page before it runs out, so `first` costs one request where `to_a` costs as many as
|
|
154
|
+
the account has pages. A walk is priced by what its pages carry, and a long one can be refused
|
|
155
|
+
for it: see [Rate limits](#rate-limits).
|
|
129
156
|
|
|
130
157
|
```ruby
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
job = jobs.first
|
|
138
|
-
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
|
|
139
|
-
job.title # => 'Furnace tune-up'
|
|
140
|
-
job.instructions # => 'Ring the doorbell twice'
|
|
141
|
-
job.status # => 'requires_invoicing'
|
|
142
|
-
job.total # => 260.0
|
|
143
|
-
job.quote_total # => 240.0
|
|
144
|
-
job.created_at # => 2026-05-10 09:15:00
|
|
158
|
+
account.jobs # => an Enumerable of every job, nothing fetched yet
|
|
159
|
+
account.jobs.past # => the ones dated before now, nothing fetched yet
|
|
160
|
+
account.jobs.upcoming # => the ones dated from now on, nothing fetched yet
|
|
161
|
+
account.jobs.past(1.year) # => only as far back as a year, which is fewer pages to read
|
|
162
|
+
account.jobs.past.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else about them
|
|
145
163
|
```
|
|
146
164
|
|
|
147
|
-
###
|
|
165
|
+
### Lines
|
|
148
166
|
|
|
149
167
|
What the work actually was, where the title is only what somebody called it. Asked for the
|
|
150
168
|
same way as anything nested, since a page costs what it carries:
|
|
151
169
|
|
|
152
170
|
```ruby
|
|
153
|
-
job =
|
|
171
|
+
job = account.jobs.includes(:lines).find 'Njc5MTk5'
|
|
154
172
|
|
|
155
|
-
job.
|
|
156
|
-
|
|
173
|
+
job.description # => 'Fix the sink', the title somebody typed on the job
|
|
174
|
+
job.lines.map(&:name) # => ['Bathroom Faucet Installation', 'Change Toilet Valve']
|
|
157
175
|
|
|
158
|
-
job.
|
|
159
|
-
line
|
|
176
|
+
line = job.lines.first
|
|
177
|
+
line.id # => 'Njc5MjAw', the ID Jobber files the line by
|
|
160
178
|
line.quantity # => 3, whole where Jobber's own Float has nothing after the point, and 3.5
|
|
161
179
|
# where it has: `3 Faucets`, or `3.5 Hours` for what was really billed
|
|
162
180
|
line.name # => 'Bathroom Faucet Installation'
|
|
163
|
-
line.
|
|
164
|
-
|
|
181
|
+
line.description # => 'Replace washers and reseat', what the line says beyond its name
|
|
182
|
+
line.amount # => 285.0, what the line comes to, which Jobber calls totalPrice
|
|
165
183
|
```
|
|
166
184
|
|
|
167
|
-
Every line Jobber holds is in the list, up to twenty of them, in the order it holds them
|
|
168
|
-
|
|
169
|
-
nothing and nothing arrives, so `oauth.jobs.first.line_items` is empty where the query never
|
|
185
|
+
Every line Jobber holds is in the list, up to twenty of them, in the order it holds them. Ask
|
|
186
|
+
for nothing and nothing arrives, so `account.jobs.first.lines` is empty where the query never
|
|
170
187
|
named them.
|
|
171
188
|
|
|
172
189
|
### Invoices
|
|
@@ -174,103 +191,83 @@ named them.
|
|
|
174
191
|
Fetch a non-draft invoice from Jobber:
|
|
175
192
|
|
|
176
193
|
```ruby
|
|
177
|
-
invoice =
|
|
194
|
+
invoice = account.invoices.find 'MjU3ODA0'
|
|
178
195
|
invoice.id # => 'MjU3ODA0'
|
|
179
|
-
invoice.
|
|
180
|
-
invoice.
|
|
181
|
-
invoice.
|
|
182
|
-
invoice.completed_at # => 2026-05-22 14:32:53
|
|
196
|
+
invoice.job.id # => 'Z2lkOi8vS', the job the invoice bills, or nil where none
|
|
197
|
+
invoice.amount # => 40.30, in dollars, as a BigDecimal
|
|
198
|
+
invoice.fulfilled_at # => 2026-05-22 14:32:53, when the job was finished, or the invoice issued
|
|
183
199
|
```
|
|
184
200
|
|
|
185
201
|
### Visits
|
|
186
202
|
|
|
187
|
-
Walk the account's visits, oldest first, the same way as its jobs:
|
|
203
|
+
Walk the account's visits, oldest first, the same way as its jobs, or reach one by ID:
|
|
188
204
|
|
|
189
205
|
```ruby
|
|
190
|
-
visits
|
|
191
|
-
|
|
192
|
-
oauth.visits.past # => the ones dated before now, nothing fetched yet
|
|
193
|
-
oauth.visits.upcoming(3.months) # => only as far ahead as three months
|
|
194
|
-
oauth.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
206
|
+
account.visits.upcoming(3.months) # => only as far ahead as three months
|
|
207
|
+
account.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
195
208
|
|
|
196
|
-
visit = visits.
|
|
209
|
+
visit = account.visits.find 'Z2lkOi8vS'
|
|
197
210
|
visit.id # => 'Z2lkOi8vS'
|
|
198
|
-
visit.
|
|
199
|
-
visit.title # => 'Furnace tune-up'
|
|
200
|
-
visit.job_id # => 'Z2lkOi8vS'
|
|
211
|
+
visit.description # => 'Furnace tune-up', or nil where nobody titled it
|
|
201
212
|
visit.starts_at # => 2026-08-09 14:00:00
|
|
202
213
|
visit.ends_at # => 2026-08-09 16:00:00
|
|
203
|
-
visit.
|
|
204
|
-
visit.
|
|
214
|
+
visit.anytime? # => false
|
|
215
|
+
visit.confirmed? # => true, which Jobber alone asks a client
|
|
216
|
+
visit.job.id # => 'Z2lkOi8vSm9i', the job the stop belongs to, where it happens and for whom
|
|
205
217
|
```
|
|
206
218
|
|
|
207
|
-
###
|
|
219
|
+
### Locations and customers
|
|
208
220
|
|
|
209
221
|
Jobber prices a query by what it brings back, so nothing nested comes back unless it is
|
|
210
|
-
asked for. Chain `includes` the way Active Record does, on
|
|
222
|
+
asked for. Chain `includes` the way Active Record does, on jobs; a visit happens where its job
|
|
223
|
+
does, so it names the job and nothing else:
|
|
211
224
|
|
|
212
225
|
```ruby
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
visit.property.latitude # => 35.77
|
|
228
|
-
visit.property.longitude # => -78.63
|
|
229
|
-
visit.property.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC',
|
|
230
|
-
# zip: '27601', latitude: 35.77, longitude: -78.63 }
|
|
231
|
-
visit.property.client.name # => whoever the place sits on the file of
|
|
226
|
+
job = account.jobs.includes(location: :customer).find 'Z2lkOi8vSm9i'
|
|
227
|
+
|
|
228
|
+
job.location.id # => 'Z2lkOi8vS'
|
|
229
|
+
job.location.street # => '1 Main St'
|
|
230
|
+
job.location.city # => 'Raleigh'
|
|
231
|
+
job.location.zip # => '27601'
|
|
232
|
+
job.location.latitude # => 35.77
|
|
233
|
+
job.location.longitude # => -78.63
|
|
234
|
+
|
|
235
|
+
job.location.customer.name # => 'Jane', or the business's name where the client is one.
|
|
236
|
+
# Never an empty string: a blank first name falls through
|
|
237
|
+
job.location.customer.surname # => 'Doe'
|
|
238
|
+
job.location.customer.email # => 'jane@example.com'
|
|
239
|
+
job.location.customer.phone # => '5553335555', the ten digits to dial, or nil
|
|
232
240
|
```
|
|
233
241
|
|
|
234
|
-
Ask for nothing and nothing arrives: `
|
|
235
|
-
|
|
242
|
+
Ask for nothing and nothing arrives: `account.jobs.first.location` is nil where the query
|
|
243
|
+
never named one.
|
|
236
244
|
|
|
237
245
|
### Rate limits
|
|
238
246
|
|
|
239
247
|
Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket of
|
|
240
248
|
query cost that drains as it is asked and refills at a rate it reports. This gem does nothing
|
|
241
|
-
about either
|
|
249
|
+
about either. It never sleeps, and it never asks a second time. What it does is say exactly
|
|
242
250
|
what happened, so the caller can decide:
|
|
243
251
|
|
|
244
252
|
```
|
|
245
253
|
Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
|
|
246
254
|
```
|
|
247
255
|
|
|
248
|
-
That one is a `Jbr::
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
error.available # => 1254, what the bucket held when it was asked
|
|
254
|
-
error.maximum # => 10000, what the bucket holds when full — a cost above it never fits
|
|
255
|
-
error.restore_rate # => 500, points a second
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
That arrives as a `Jbr::Error`. 631 points short of a query the bucket holds five times over,
|
|
259
|
-
which a second would have refilled — worth asking again. A cost above `maximumAvailable` is
|
|
260
|
-
worth nothing but a smaller query. Either way the decision belongs to whoever called: from a
|
|
261
|
-
background job, letting it fail so the queue brings it back is better than a worker asleep
|
|
262
|
-
holding a transaction open.
|
|
256
|
+
That one is a `Jbr::Throttled`, a `Company::Throttled` for a refusal worth asking again: 631 points
|
|
257
|
+
short of a query the bucket holds five times over, which a second would have refilled. A cost
|
|
258
|
+
above what the bucket holds when full is worth nothing but a smaller query. Either way the
|
|
259
|
+
decision belongs to whoever called: from a background job, letting it fail so the queue brings
|
|
260
|
+
it back is better than a worker asleep holding a transaction open.
|
|
263
261
|
|
|
264
262
|
Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
|
|
265
263
|
twenty lines to a job, and twenty jobs or visits to a page.
|
|
266
264
|
|
|
267
265
|
`ids` is the cheap way to walk an account. It asks for the ID and nothing else, which prices
|
|
268
|
-
a row at a fraction of a record and buys a hundred of them to a page
|
|
269
|
-
|
|
270
|
-
its own with `find`, one background job at a time:
|
|
266
|
+
a row at a fraction of a record and buys a hundred of them to a page. Reach for it where each
|
|
267
|
+
record is then read on its own with `find`, one background job at a time:
|
|
271
268
|
|
|
272
269
|
```ruby
|
|
273
|
-
|
|
270
|
+
account.jobs.past.ids.each { |id| ImportJob.perform_later id }
|
|
274
271
|
```
|
|
275
272
|
|
|
276
273
|
### Events
|
|
@@ -286,7 +283,8 @@ event.item_id # => 'job-1'
|
|
|
286
283
|
|
|
287
284
|
## Available mocks
|
|
288
285
|
|
|
289
|
-
|
|
286
|
+
Touch `Jbr.mock` once, in an app's test helper, and every `Jbr::Account` answers from it
|
|
287
|
+
instead of Jobber. Each slot takes a Hash by the vocabulary's names.
|
|
290
288
|
|
|
291
289
|
### Credentials
|
|
292
290
|
|
|
@@ -302,24 +300,24 @@ Mock an error when creating credentials:
|
|
|
302
300
|
Jbr.mock.oauth_error = 'Flow rejected'
|
|
303
301
|
```
|
|
304
302
|
|
|
305
|
-
Mock a custom
|
|
303
|
+
Mock a custom authorize URL:
|
|
306
304
|
|
|
307
305
|
```ruby
|
|
308
306
|
Jbr.mock.oauth_url = 'https://example.com'
|
|
309
307
|
```
|
|
310
308
|
|
|
311
|
-
Mock the
|
|
309
|
+
Mock the business the credentials belong to. Left unset, its ID is `account-01`:
|
|
312
310
|
|
|
313
311
|
```ruby
|
|
314
|
-
Jbr.mock.
|
|
312
|
+
Jbr.mock.business = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
|
|
315
313
|
```
|
|
316
314
|
|
|
317
|
-
###
|
|
315
|
+
### Leads
|
|
318
316
|
|
|
319
|
-
Mock successfully
|
|
317
|
+
Mock successfully filing a lead:
|
|
320
318
|
|
|
321
319
|
```ruby
|
|
322
|
-
Jbr.mock.
|
|
320
|
+
Jbr.mock.lead = { id: 'request-01', customer: { id: 'client-01' } }
|
|
323
321
|
```
|
|
324
322
|
|
|
325
323
|
### Quotes
|
|
@@ -327,7 +325,7 @@ Jbr.mock.request = { id: 'request-01', client_id: 'client-01' }
|
|
|
327
325
|
Mock successfully fetching a quote:
|
|
328
326
|
|
|
329
327
|
```ruby
|
|
330
|
-
Jbr.mock.quote = { id: 'quote-01',
|
|
328
|
+
Jbr.mock.quote = { id: 'quote-01', lead: { id: 'request-01' } }
|
|
331
329
|
```
|
|
332
330
|
|
|
333
331
|
### Jobs
|
|
@@ -335,43 +333,34 @@ Jbr.mock.quote = { id: 'quote-01', request_id: 'request-01' }
|
|
|
335
333
|
Mock successfully fetching a job by ID:
|
|
336
334
|
|
|
337
335
|
```ruby
|
|
338
|
-
Jbr.mock.job = { id: 'job-01',
|
|
336
|
+
Jbr.mock.job = { id: 'job-01', quote: { id: 'quote-01' }, scheduled_at: Date.tomorrow.noon }
|
|
339
337
|
```
|
|
340
338
|
|
|
341
339
|
Mock the jobs the account has. The mock dates nothing it was handed: what answers to
|
|
342
|
-
`past` and to `upcoming` is whatever `scheduled_at` the app gave each one
|
|
340
|
+
`past` and to `upcoming` is whatever `scheduled_at` the app gave each one, and `find` answers
|
|
341
|
+
the one listed under the ID asked for:
|
|
343
342
|
|
|
344
343
|
```ruby
|
|
345
|
-
Jbr.mock.jobs = [ { id: 'job-01',
|
|
346
|
-
|
|
344
|
+
Jbr.mock.jobs = [ { id: 'job-01', description: 'Furnace tune-up', notes: 'Ring twice',
|
|
345
|
+
amount: 260.0, quote: { id: 'quote-01', amount: 240.0 }, created_at: Date.yesterday.noon,
|
|
347
346
|
scheduled_at: Date.yesterday.noon, completed_at: Date.today.noon,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
Mock the lines a job is made of, under the job that is made of them:
|
|
353
|
-
|
|
354
|
-
```ruby
|
|
355
|
-
Jbr.mock.jobs = [ { id: 'job-01', line_items: [
|
|
356
|
-
{ quantity: 3.0, name: 'Bathroom Faucet Installation',
|
|
357
|
-
description: 'Professional installation of a new bathroom faucet' },
|
|
358
|
-
{ quantity: 2.0, name: 'Change Toilet Valve' } ] } ]
|
|
347
|
+
lines: [ { quantity: 3.0, name: 'Bathroom Faucet Installation' }, { name: 'Trip fee' } ],
|
|
348
|
+
location: { id: 'property-01', street: '1 Main St',
|
|
349
|
+
customer: { id: 'client-01', name: 'Acme Property Management' } } } ]
|
|
359
350
|
|
|
360
|
-
|
|
361
|
-
# => '3 Bathroom Faucet Installation and 2 Change Toilet Valve'
|
|
351
|
+
account.jobs.past.first.lines.map(&:name) # => ['Bathroom Faucet Installation', 'Trip fee']
|
|
362
352
|
```
|
|
363
353
|
|
|
364
354
|
### Visits
|
|
365
355
|
|
|
366
|
-
Mock the visits the account has
|
|
356
|
+
Mock the visits the account has, dated by `starts_at`:
|
|
367
357
|
|
|
368
358
|
```ruby
|
|
369
|
-
Jbr.mock.visits = [ { id: 'visit-01',
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
client: { id: 'client-01', first_name: 'Jane' },
|
|
359
|
+
Jbr.mock.visits = [ { id: 'visit-01', description: 'Furnace tune-up',
|
|
360
|
+
location: { id: 'property-01', street: '1 Main St',
|
|
361
|
+
customer: { id: 'client-01', name: 'Jane' } },
|
|
373
362
|
starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
|
|
374
|
-
|
|
363
|
+
anytime: false, confirmed: true } ]
|
|
375
364
|
```
|
|
376
365
|
|
|
377
366
|
### Invoices
|
|
@@ -379,5 +368,6 @@ Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01'
|
|
|
379
368
|
Mock successfully fetching an invoice:
|
|
380
369
|
|
|
381
370
|
```ruby
|
|
382
|
-
Jbr.mock.invoice = { id: 'invoice-01',
|
|
371
|
+
Jbr.mock.invoice = { id: 'invoice-01', job: { id: 'job-01' }, amount: 19.99,
|
|
372
|
+
issued_at: Date.yesterday.noon }
|
|
383
373
|
```
|
data/lib/graphql/client.rb
CHANGED
|
@@ -28,20 +28,15 @@ module GraphQL
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
# Refused over cost where the endpoint names the code for it, or prices the query above
|
|
32
|
-
# what it says was left. Anything else is a refusal of the query itself.
|
|
33
31
|
def refusal_for(body)
|
|
34
32
|
cost = body['extensions'].to_h['cost'].to_h
|
|
35
33
|
available = cost['throttleStatus'].to_h['currentlyAvailable']
|
|
36
34
|
coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
|
|
37
35
|
priced = available && cost['requestedQueryCost'].to_f > available.to_f
|
|
38
36
|
|
|
39
|
-
coded || priced ? Throttled
|
|
37
|
+
(coded || priced ? Throttled : Error).new refusal(body)
|
|
40
38
|
end
|
|
41
39
|
|
|
42
|
-
# What the endpoint refused, and — where it priced the refusal — what the query would have
|
|
43
|
-
# cost against what was available. `Throttled` on its own leaves a caller unable to tell a
|
|
44
|
-
# query too big to ever run from a bucket that a moment would have refilled.
|
|
45
40
|
def refusal(body)
|
|
46
41
|
message = body['errors'].map { |error| error['message'] }.join '; '
|
|
47
42
|
cost = body['extensions'].to_h['cost'].to_h
|
|
@@ -51,6 +46,7 @@ module GraphQL
|
|
|
51
46
|
"#{message} (cost #{cost['requestedQueryCost']}, #{status['currentlyAvailable']} of " \
|
|
52
47
|
"#{status['maximumAvailable']} available, restoring #{status['restoreRate']}/s)"
|
|
53
48
|
end
|
|
49
|
+
|
|
54
50
|
def request_headers
|
|
55
51
|
{ 'Authorization' => "Bearer #{@token}", 'Content-Type' => 'application/json' }.merge @headers
|
|
56
52
|
end
|