jbr 3.13.0 → 5.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 +139 -0
- data/README.md +302 -167
- data/lib/graphql/client.rb +2 -6
- data/lib/graphql/error.rb +10 -0
- data/lib/graphql/throttled.rb +0 -9
- data/lib/jbr/account.rb +48 -15
- data/lib/jbr/authorizing.rb +43 -0
- data/lib/jbr/booking.rb +56 -0
- data/lib/jbr/collection.rb +7 -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} +8 -8
- 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/collections/technicians.rb +17 -0
- data/lib/jbr/collections/visits.rb +61 -0
- data/lib/jbr/error.rb +1 -1
- data/lib/jbr/errors/throttled.rb +6 -0
- data/lib/jbr/includable.rb +10 -8
- data/lib/jbr/listable.rb +17 -35
- data/lib/jbr/logger.rb +13 -0
- data/lib/jbr/mock/account.rb +28 -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/technicians.rb +12 -0
- data/lib/jbr/mock/visit.rb +9 -16
- data/lib/jbr/mock/visits.rb +39 -6
- data/lib/jbr/mock.rb +17 -4
- data/lib/jbr/phone.rb +5 -8
- data/lib/jbr/querying.rb +68 -0
- data/lib/jbr/reader.rb +9 -0
- data/lib/jbr/reading.rb +15 -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/technician.rb +19 -0
- data/lib/jbr/resources/visit.rb +30 -0
- data/lib/jbr/scheduled.rb +36 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +45 -45
- metadata +46 -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 -44
- data/lib/jbr/mock/client.rb +0 -17
- data/lib/jbr/mock/job.rb +0 -31
- data/lib/jbr/mock/line_item.rb +0 -16
- 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/visits.rb +0 -36
- /data/lib/jbr/{refused.rb → errors/refused.rb} +0 -0
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', '~> 5.0'
|
|
17
|
+
|
|
18
|
+
Semantic Versioning promises that `~> major.minor` never crosses a breaking change, so the pin
|
|
19
|
+
takes every 5.x release and stops short of 6.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,126 @@ 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
|
|
102
|
+
```
|
|
103
|
+
|
|
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
|
+
### Scopes
|
|
108
|
+
|
|
109
|
+
Jobber has no scope parameter: an app is granted what its Developer Center page ticks, and a
|
|
110
|
+
query selecting anything it was not granted is refused whole rather than answered with the one
|
|
111
|
+
field empty. So a scope left unticked is not a nil somewhere, it is every query that touches it
|
|
112
|
+
failing. What each reader here needs:
|
|
113
|
+
|
|
114
|
+
| What a caller asks for | Object to tick |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `account.jobs`, `job.lines` | Jobs |
|
|
117
|
+
| `job.location`, `location.customer`, `visit.job` | Jobs and Clients |
|
|
118
|
+
| `account.visits`, `assigned_to` | Scheduled Items |
|
|
119
|
+
| `account.technicians`, `visit.technicians`, `includes(:technicians)` | Users |
|
|
120
|
+
| `account.quotes` | Quotes |
|
|
121
|
+
| `account.invoices` | Invoices |
|
|
122
|
+
| `account.leads.create` | Requests and Clients, both writing |
|
|
123
|
+
|
|
124
|
+
Jobber files a visit under **Scheduled Items**, which is one object covering visits,
|
|
125
|
+
assessments, tasks and calendar events -- so there is no scope to add for the kinds of booked
|
|
126
|
+
time this gem does not read yet.
|
|
127
|
+
|
|
128
|
+
`assigned_to` is the one worth knowing: narrowing to a technician needs no Users, because
|
|
129
|
+
Jobber does the narrowing and no user is ever selected. Reading *who* is on a visit is what
|
|
130
|
+
needs it.
|
|
131
|
+
|
|
132
|
+
The mapping above is read off what each query selects, not published by Jobber, so an app that
|
|
133
|
+
is refused has one more object to tick than this table knows about. The names are the ones the
|
|
134
|
+
Developer Center shows beside the checkboxes.
|
|
135
|
+
|
|
136
|
+
An app that asks for what it was never granted is not left broken. Jobber answers
|
|
137
|
+
`An object of type User was hidden due to permissions` and hides the object, so a reader added
|
|
138
|
+
after an account authorized would otherwise take down every query carrying it:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Jbr.logger = Rails.logger # standard error until an app names somewhere better
|
|
142
|
+
account.technicians.to_a # => [], with a line in the log naming the type to tick the scope for
|
|
143
|
+
account.visits.upcoming(1.week).includes(:technicians).to_a # => the week, with nobody on it
|
|
88
144
|
```
|
|
89
145
|
|
|
90
|
-
|
|
146
|
+
What Jobber hides is the object and not the query, so whatever came back beside it is kept: a
|
|
147
|
+
list that asked for one thing too many still answers everything else it asked for. Jobber codes
|
|
148
|
+
this refusal not at all, so the words are the only signal there is -- and only those words are
|
|
149
|
+
carried on from. Anything else still raises: an empty list is a poor place to hide a fault.
|
|
150
|
+
|
|
151
|
+
### Leads
|
|
91
152
|
|
|
92
|
-
|
|
153
|
+
File a request on the account's board, against the client answering to the phone and the
|
|
154
|
+
property at the address, opening either where Jobber has none:
|
|
93
155
|
|
|
94
156
|
```ruby
|
|
95
|
-
|
|
96
|
-
email: 'jane@example.com',
|
|
97
|
-
|
|
98
|
-
|
|
157
|
+
lead = account.leads.create name: 'Jane', surname: 'Doe', phone: '5553335555',
|
|
158
|
+
email: 'jane@example.com', description: 'New Plumber Lead', notes: 'Needs new faucet',
|
|
159
|
+
source: nil, # Jobber has no source for a request, so this one is dropped
|
|
160
|
+
address: { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601' }
|
|
161
|
+
lead.id # => 'Z2lkOi8vSm9iYmVyL'
|
|
162
|
+
lead.customer.id # => 'MwMTU0Mg', the client the request was opened against
|
|
99
163
|
```
|
|
100
164
|
|
|
165
|
+
A mutation Jobber takes but will not act on, a phone it calls invalid or an email it already
|
|
166
|
+
holds, raises `Jbr::Error` with what Jobber said.
|
|
167
|
+
|
|
101
168
|
### Quotes
|
|
102
169
|
|
|
103
|
-
Fetch a quote from Jobber:
|
|
170
|
+
Fetch a quote from Jobber, and the lead it answers:
|
|
104
171
|
|
|
105
172
|
```ruby
|
|
106
|
-
quote =
|
|
173
|
+
quote = account.quotes.find 'Z2lkOi8vS'
|
|
107
174
|
quote.id # => 'Z2lkOi8vS'
|
|
108
|
-
quote.
|
|
175
|
+
quote.lead.id # => 'Z2lkOi8vSm9iYmVyL', the request the quote answers, or nil where none
|
|
109
176
|
```
|
|
110
177
|
|
|
111
178
|
### Jobs
|
|
@@ -113,63 +180,54 @@ quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
|
|
|
113
180
|
Fetch a job from Jobber by the ID it is filed under:
|
|
114
181
|
|
|
115
182
|
```ruby
|
|
116
|
-
job =
|
|
183
|
+
job = account.jobs.find 'Njc5MTk5'
|
|
117
184
|
job.id # => 'Z2lkOi8vS'
|
|
118
|
-
job.
|
|
185
|
+
job.quote.id # => 'Z2lkOi8vS', the quote the job was won with, or nil where none was
|
|
186
|
+
job.quote.amount # => 240.0, in dollars, as a BigDecimal
|
|
187
|
+
job.amount # => 260.0
|
|
188
|
+
job.notes # => 'Ring the doorbell twice', what Jobber calls the instructions
|
|
189
|
+
job.created_at # => 2026-05-10 09:15:00
|
|
119
190
|
job.scheduled_at # => 2026-05-14 23:02:52
|
|
120
191
|
job.completed_at # => 2026-05-18 11:36:13
|
|
121
192
|
```
|
|
122
193
|
|
|
123
194
|
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
|
-
|
|
129
|
-
|
|
130
|
-
```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
|
|
195
|
+
you meant, a duration measured from the same now the half is split at, and a walk that stops
|
|
196
|
+
at a boundary reads only the pages up to it. Jobber is asked for a page at a time, and only
|
|
197
|
+
once the page before it runs out, so `first` costs one request where `to_a` costs as many as
|
|
198
|
+
the account has pages. A walk is priced by what its pages carry, and a long one can be refused
|
|
199
|
+
for it: see [Rate limits](#rate-limits).
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
account.jobs # => an Enumerable of every job, nothing fetched yet
|
|
203
|
+
account.jobs.past # => the ones dated before now, nothing fetched yet
|
|
204
|
+
account.jobs.upcoming # => the ones dated from now on, nothing fetched yet
|
|
205
|
+
account.jobs.past(1.year) # => only as far back as a year, which is fewer pages to read
|
|
206
|
+
account.jobs.past.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else about them
|
|
145
207
|
```
|
|
146
208
|
|
|
147
|
-
###
|
|
209
|
+
### Lines
|
|
148
210
|
|
|
149
211
|
What the work actually was, where the title is only what somebody called it. Asked for the
|
|
150
212
|
same way as anything nested, since a page costs what it carries:
|
|
151
213
|
|
|
152
214
|
```ruby
|
|
153
|
-
job =
|
|
215
|
+
job = account.jobs.includes(:lines).find 'Njc5MTk5'
|
|
154
216
|
|
|
155
|
-
job.
|
|
156
|
-
|
|
217
|
+
job.description # => 'Fix the sink', the title somebody typed on the job
|
|
218
|
+
job.lines.map(&:name) # => ['Bathroom Faucet Installation', 'Change Toilet Valve']
|
|
157
219
|
|
|
158
|
-
job.
|
|
159
|
-
line
|
|
220
|
+
line = job.lines.first
|
|
221
|
+
line.id # => 'Njc5MjAw', the ID Jobber files the line by
|
|
160
222
|
line.quantity # => 3, whole where Jobber's own Float has nothing after the point, and 3.5
|
|
161
223
|
# where it has: `3 Faucets`, or `3.5 Hours` for what was really billed
|
|
162
|
-
line.id # => 'Njc5MjAw', the ID Jobber files the line by
|
|
163
224
|
line.name # => 'Bathroom Faucet Installation'
|
|
164
225
|
line.description # => 'Replace washers and reseat', what the line says beyond its name
|
|
165
|
-
line.amount # => 285.0, what the line comes to
|
|
166
|
-
line.to_s # => '3 Bathroom Faucet Installation', how many of what, and the name alone where
|
|
167
|
-
# Jobber holds no quantity for the line
|
|
226
|
+
line.amount # => 285.0, what the line comes to, which Jobber calls totalPrice
|
|
168
227
|
```
|
|
169
228
|
|
|
170
|
-
Every line Jobber holds is in the list, up to twenty of them, in the order it holds them
|
|
171
|
-
|
|
172
|
-
nothing and nothing arrives, so `oauth.jobs.first.line_items` is empty where the query never
|
|
229
|
+
Every line Jobber holds is in the list, up to twenty of them, in the order it holds them. Ask
|
|
230
|
+
for nothing and nothing arrives, so `account.jobs.first.lines` is empty where the query never
|
|
173
231
|
named them.
|
|
174
232
|
|
|
175
233
|
### Invoices
|
|
@@ -177,103 +235,174 @@ named them.
|
|
|
177
235
|
Fetch a non-draft invoice from Jobber:
|
|
178
236
|
|
|
179
237
|
```ruby
|
|
180
|
-
invoice =
|
|
238
|
+
invoice = account.invoices.find 'MjU3ODA0'
|
|
181
239
|
invoice.id # => 'MjU3ODA0'
|
|
182
|
-
invoice.
|
|
183
|
-
invoice.
|
|
184
|
-
invoice.
|
|
185
|
-
invoice.completed_at # => 2026-05-22 14:32:53
|
|
240
|
+
invoice.job.id # => 'Z2lkOi8vS', the job the invoice bills, or nil where none
|
|
241
|
+
invoice.amount # => 40.30, in dollars, as a BigDecimal
|
|
242
|
+
invoice.fulfilled_at # => 2026-05-22 14:32:53, when the job was finished, or the invoice issued
|
|
186
243
|
```
|
|
187
244
|
|
|
188
245
|
### Visits
|
|
189
246
|
|
|
190
|
-
|
|
247
|
+
A visit is any booked time: somebody is out somewhere for an hour. A stop of a job is one. So is
|
|
248
|
+
an *assessment*, the stop booked to go and look at work before there is a job, which Jobber hangs
|
|
249
|
+
off the request -- the lead. So is an event or a task, an hour blocked out against nothing at all.
|
|
250
|
+
All of them are scheduled items, all of them mean the pro is not free, and all are read from one
|
|
251
|
+
list. Jobber has exactly four kinds -- a visit, an assessment, an event and a task -- so none is
|
|
252
|
+
read past. A reminder is named by the filter's enum and is not a scheduled item at all, so it
|
|
253
|
+
cannot come back.
|
|
254
|
+
|
|
255
|
+
Where a stop is, is asked for: Jobber hangs the property off each kind rather than off what they
|
|
256
|
+
share, and prices it per row, so `includes(:location)` is what turns it on.
|
|
191
257
|
|
|
192
258
|
```ruby
|
|
193
|
-
visits
|
|
194
|
-
|
|
195
|
-
oauth.visits.past # => the ones dated before now, nothing fetched yet
|
|
196
|
-
oauth.visits.upcoming(3.months) # => only as far ahead as three months
|
|
197
|
-
oauth.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
259
|
+
account.visits.upcoming(3.months) # => only as far ahead as three months
|
|
260
|
+
account.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
198
261
|
|
|
199
|
-
visit = visits.first
|
|
262
|
+
visit = account.visits.upcoming(1.week).first
|
|
200
263
|
visit.id # => 'Z2lkOi8vS'
|
|
201
|
-
visit.
|
|
202
|
-
visit.title # => 'Furnace tune-up'
|
|
203
|
-
visit.job_id # => 'Z2lkOi8vS'
|
|
264
|
+
visit.description # => 'Furnace tune-up', or nil where nobody titled it
|
|
204
265
|
visit.starts_at # => 2026-08-09 14:00:00
|
|
205
266
|
visit.ends_at # => 2026-08-09 16:00:00
|
|
206
|
-
visit.
|
|
207
|
-
visit.
|
|
267
|
+
visit.anytime? # => false
|
|
268
|
+
visit.confirmed? # => true, which Jobber alone asks a client
|
|
269
|
+
visit.location # => where the stop is, where `includes(:location)` asked
|
|
270
|
+
visit.job # => the job the stop belongs to, or nil where no job was booked for it
|
|
271
|
+
visit.lead # => the request it was booked against, or nil where no lead was booked for it
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Either kind alone is one question rather than two, asked of Jobber rather than sifted here:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
account.visits.upcoming(1.week).for_jobs # => only the stops of jobs
|
|
278
|
+
account.visits.upcoming(1.week).for_leads # => only the assessments
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**A schedule is read by the window.** Jobber will not list a scheduled item without one, so
|
|
282
|
+
`account.visits` with nothing narrowing it raises rather than walking every visit there ever
|
|
283
|
+
was. `between`, `upcoming` and `past` all supply one. Jobber's window also takes two moments
|
|
284
|
+
and no nil, so `upcoming` and `past` with no duration -- which on a list of jobs means as far
|
|
285
|
+
as there is -- reach a year here, and no further.
|
|
286
|
+
|
|
287
|
+
Book one to go and look at work nobody has priced, against the client answering to the phone
|
|
288
|
+
and the property at the address, opening either where Jobber has none:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
monday = Time.find_zone('America/New_York').local(2026, 9, 21, 13)
|
|
292
|
+
visit = account.visits.create name: 'Jane', surname: 'Doe', phone: '5553335555',
|
|
293
|
+
email: 'jane@example.com', address: { street: '1 Main St', city: 'Newark', zip: '07102' },
|
|
294
|
+
description: 'Look at the roof', notes: 'Ring twice', source: 'Website',
|
|
295
|
+
starts_at: monday, ends_at: monday + 1.hour, technicians: [technician]
|
|
296
|
+
|
|
297
|
+
visit.id # => 'Z2lkOi8vSm9iYmVyL0Fzc2Vzc21lbnQv', the assessment Jobber filed
|
|
298
|
+
visit.lead.id # => 'Z2lkOi8vSm9iYmVyL1JlcXVlc3Qv', the request it hangs off
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
One mutation files the lead, the hour and the crew, and what comes back is what Jobber stored
|
|
302
|
+
rather than what it was asked for. Jobber has no source for a request, so `source:` is dropped.
|
|
303
|
+
`ends_at:` may be nil, for a stop booked to a day rather than an hour.
|
|
304
|
+
|
|
305
|
+
**`starts_at:` has to know its zone.** Jobber books in the words of whoever is going -- a date,
|
|
306
|
+
a local time, and the zone they are in -- rather than the moment in UTC those come to. A bare
|
|
307
|
+
`Time` names an offset, and an offset is not a zone: the same one stands for several, and none
|
|
308
|
+
of them says when the clocks go back. So hand over a `Time.zone` moment; a `Time` is refused
|
|
309
|
+
before anything is opened.
|
|
310
|
+
|
|
311
|
+
### The schedule
|
|
312
|
+
|
|
313
|
+
Jobber calls a technician a user, and reading one needs the Users scope. Without it
|
|
314
|
+
Jobber refuses the whole query rather than the one field, so nothing asks who is on a visit
|
|
315
|
+
unless a caller does. Narrowing *to* a technician needs no such scope -- only reading one back:
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
technician = account.technicians.first
|
|
319
|
+
technician.id, technician.name, technician.surname # => 'Z2lkOi8vVXNlc', 'Grace', 'Hopper'
|
|
320
|
+
|
|
321
|
+
account.visits.includes(:technicians).upcoming.first.technicians # => [#<Jbr::Technician>, ...]
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
One technician's week is the visits in it narrowed to them:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
monday = Date.today.beginning_of_week.in_time_zone
|
|
328
|
+
account.visits.between(monday, monday + 1.week).assigned_to(technician).each do |visit|
|
|
329
|
+
visit.starts_at, visit.ends_at, visit.job.id
|
|
330
|
+
end
|
|
208
331
|
```
|
|
209
332
|
|
|
210
|
-
|
|
333
|
+
Jobber narrows a list of visits by who is on it, so `assigned_to` puts the technician into the
|
|
334
|
+
same filter as the window: nobody else's visits are answered, paged or paid for, and the crew
|
|
335
|
+
is not read at all unless `includes(:technicians)` asks. The two narrowings land in the one
|
|
336
|
+
filter, so a caller may ask for the week and the technician in either order.
|
|
337
|
+
|
|
338
|
+
`assigned_to` therefore needs no Users scope. Reading *who* is on a visit does.
|
|
339
|
+
|
|
340
|
+
A visit is still not all Jobber schedules. A task, an event and the two kinds of reminder sit
|
|
341
|
+
on the same calendar and are read past unread, because Jobber's filter takes one kind and not
|
|
342
|
+
two, so both the kinds that are visits are asked for and the rest let go as they arrive.
|
|
343
|
+
|
|
344
|
+
One thing worth knowing about that list: `scheduledItems` answers assigned work only unless it
|
|
345
|
+
is told otherwise, so every window carries `includeUnassigned: true` -- without it a week is
|
|
346
|
+
quietly missing every stop nobody has been put on yet. It carries `includeUnscheduled: false`
|
|
347
|
+
in the same breath, because asking for unassigned work through `schedulingAspects: [ALL]` is
|
|
348
|
+
answered with unscheduled work as well: requests filed and never booked, with no hour to
|
|
349
|
+
occupy and no place in any window.
|
|
350
|
+
|
|
351
|
+
### Locations and customers
|
|
211
352
|
|
|
212
353
|
Jobber prices a query by what it brings back, so nothing nested comes back unless it is
|
|
213
|
-
asked for. Chain `includes` the way Active Record does, on
|
|
354
|
+
asked for. Chain `includes` the way Active Record does, on jobs; a visit happens where its job
|
|
355
|
+
does, so it names the job and nothing else:
|
|
214
356
|
|
|
215
357
|
```ruby
|
|
216
|
-
|
|
358
|
+
job = account.jobs.includes(location: :customer).find 'Z2lkOi8vSm9i'
|
|
217
359
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
visit.client.phone # => '5553335555', the reachable North American number, or nil
|
|
360
|
+
job.location.id # => 'Z2lkOi8vS'
|
|
361
|
+
job.location.street # => '1 Main St'
|
|
362
|
+
job.location.city # => 'Raleigh'
|
|
363
|
+
job.location.zip # => '27601'
|
|
364
|
+
job.location.latitude # => 35.77
|
|
365
|
+
job.location.longitude # => -78.63
|
|
225
366
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
visit.property.longitude # => -78.63
|
|
232
|
-
visit.property.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC',
|
|
233
|
-
# zip: '27601', latitude: 35.77, longitude: -78.63 }
|
|
234
|
-
visit.property.client.name # => whoever the place sits on the file of
|
|
367
|
+
job.location.customer.name # => 'Jane', or the business's name where the client is one.
|
|
368
|
+
# Never an empty string: a blank first name falls through
|
|
369
|
+
job.location.customer.surname # => 'Doe'
|
|
370
|
+
job.location.customer.email # => 'jane@example.com'
|
|
371
|
+
job.location.customer.phone # => '5553335555', the ten digits to dial, or nil
|
|
235
372
|
```
|
|
236
373
|
|
|
237
|
-
Ask for nothing and nothing arrives: `
|
|
238
|
-
|
|
374
|
+
Ask for nothing and nothing arrives: `account.jobs.first.location` is nil where the query
|
|
375
|
+
never named one.
|
|
239
376
|
|
|
240
377
|
### Rate limits
|
|
241
378
|
|
|
242
379
|
Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket of
|
|
243
380
|
query cost that drains as it is asked and refills at a rate it reports. This gem does nothing
|
|
244
|
-
about either
|
|
381
|
+
about either. It never sleeps, and it never asks a second time. What it does is say exactly
|
|
245
382
|
what happened, so the caller can decide:
|
|
246
383
|
|
|
247
384
|
```
|
|
248
385
|
Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)
|
|
249
386
|
```
|
|
250
387
|
|
|
251
|
-
That one is a `Jbr::
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
error.available # => 1254, what the bucket held when it was asked
|
|
257
|
-
error.maximum # => 10000, what the bucket holds when full — a cost above it never fits
|
|
258
|
-
error.restore_rate # => 500, points a second
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
That arrives as a `Jbr::Error`. 631 points short of a query the bucket holds five times over,
|
|
262
|
-
which a second would have refilled — worth asking again. A cost above `maximumAvailable` is
|
|
263
|
-
worth nothing but a smaller query. Either way the decision belongs to whoever called: from a
|
|
264
|
-
background job, letting it fail so the queue brings it back is better than a worker asleep
|
|
265
|
-
holding a transaction open.
|
|
388
|
+
That one is a `Jbr::Throttled`, a `Company::Throttled` for a refusal worth asking again: 631 points
|
|
389
|
+
short of a query the bucket holds five times over, which a second would have refilled. A cost
|
|
390
|
+
above what the bucket holds when full is worth nothing but a smaller query. Either way the
|
|
391
|
+
decision belongs to whoever called: from a background job, letting it fail so the queue brings
|
|
392
|
+
it back is better than a worker asleep holding a transaction open.
|
|
266
393
|
|
|
267
394
|
Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
|
|
268
|
-
twenty lines to a job, and twenty jobs or
|
|
395
|
+
twenty lines to a job, ten technicians to a visit, and twenty jobs, visits or technicians to a
|
|
396
|
+
page, whichever kinds the page holds. Who is on a visit is priced on top of every row that
|
|
397
|
+
carries it, so `includes(:technicians)` costs more per page -- which is why narrowing to one
|
|
398
|
+
technician does not use it.
|
|
269
399
|
|
|
270
400
|
`ids` is the cheap way to walk an account. It asks for the ID and nothing else, which prices
|
|
271
|
-
a row at a fraction of a record and buys a hundred of them to a page
|
|
272
|
-
|
|
273
|
-
its own with `find`, one background job at a time:
|
|
401
|
+
a row at a fraction of a record and buys a hundred of them to a page. Reach for it where each
|
|
402
|
+
record is then read on its own with `find`, one background job at a time:
|
|
274
403
|
|
|
275
404
|
```ruby
|
|
276
|
-
|
|
405
|
+
account.jobs.past.ids.each { |id| ImportJob.perform_later id }
|
|
277
406
|
```
|
|
278
407
|
|
|
279
408
|
### Events
|
|
@@ -289,7 +418,8 @@ event.item_id # => 'job-1'
|
|
|
289
418
|
|
|
290
419
|
## Available mocks
|
|
291
420
|
|
|
292
|
-
|
|
421
|
+
Touch `Jbr.mock` once, in an app's test helper, and every `Jbr::Account` answers from it
|
|
422
|
+
instead of Jobber. Each slot takes a Hash by the vocabulary's names.
|
|
293
423
|
|
|
294
424
|
### Credentials
|
|
295
425
|
|
|
@@ -305,24 +435,24 @@ Mock an error when creating credentials:
|
|
|
305
435
|
Jbr.mock.oauth_error = 'Flow rejected'
|
|
306
436
|
```
|
|
307
437
|
|
|
308
|
-
Mock a custom
|
|
438
|
+
Mock a custom authorize URL:
|
|
309
439
|
|
|
310
440
|
```ruby
|
|
311
441
|
Jbr.mock.oauth_url = 'https://example.com'
|
|
312
442
|
```
|
|
313
443
|
|
|
314
|
-
Mock the
|
|
444
|
+
Mock the business the credentials belong to. Left unset, its ID is `account-01`:
|
|
315
445
|
|
|
316
446
|
```ruby
|
|
317
|
-
Jbr.mock.
|
|
447
|
+
Jbr.mock.business = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
|
|
318
448
|
```
|
|
319
449
|
|
|
320
|
-
###
|
|
450
|
+
### Leads
|
|
321
451
|
|
|
322
|
-
Mock successfully
|
|
452
|
+
Mock successfully filing a lead:
|
|
323
453
|
|
|
324
454
|
```ruby
|
|
325
|
-
Jbr.mock.
|
|
455
|
+
Jbr.mock.lead = { id: 'request-01', customer: { id: 'client-01' } }
|
|
326
456
|
```
|
|
327
457
|
|
|
328
458
|
### Quotes
|
|
@@ -330,7 +460,7 @@ Jbr.mock.request = { id: 'request-01', client_id: 'client-01' }
|
|
|
330
460
|
Mock successfully fetching a quote:
|
|
331
461
|
|
|
332
462
|
```ruby
|
|
333
|
-
Jbr.mock.quote = { id: 'quote-01',
|
|
463
|
+
Jbr.mock.quote = { id: 'quote-01', lead: { id: 'request-01' } }
|
|
334
464
|
```
|
|
335
465
|
|
|
336
466
|
### Jobs
|
|
@@ -338,49 +468,54 @@ Jbr.mock.quote = { id: 'quote-01', request_id: 'request-01' }
|
|
|
338
468
|
Mock successfully fetching a job by ID:
|
|
339
469
|
|
|
340
470
|
```ruby
|
|
341
|
-
Jbr.mock.job = { id: 'job-01',
|
|
471
|
+
Jbr.mock.job = { id: 'job-01', quote: { id: 'quote-01' }, scheduled_at: Date.tomorrow.noon }
|
|
342
472
|
```
|
|
343
473
|
|
|
344
474
|
Mock the jobs the account has. The mock dates nothing it was handed: what answers to
|
|
345
|
-
`past` and to `upcoming` is whatever `scheduled_at` the app gave each one
|
|
475
|
+
`past` and to `upcoming` is whatever `scheduled_at` the app gave each one, and `find` answers
|
|
476
|
+
the one listed under the ID asked for:
|
|
346
477
|
|
|
347
478
|
```ruby
|
|
348
|
-
Jbr.mock.jobs = [ { id: 'job-01',
|
|
349
|
-
|
|
479
|
+
Jbr.mock.jobs = [ { id: 'job-01', description: 'Furnace tune-up', notes: 'Ring twice',
|
|
480
|
+
amount: 260.0, quote: { id: 'quote-01', amount: 240.0 }, created_at: Date.yesterday.noon,
|
|
350
481
|
scheduled_at: Date.yesterday.noon, completed_at: Date.today.noon,
|
|
351
|
-
|
|
352
|
-
|
|
482
|
+
lines: [ { quantity: 3.0, name: 'Bathroom Faucet Installation' }, { name: 'Trip fee' } ],
|
|
483
|
+
location: { id: 'property-01', street: '1 Main St',
|
|
484
|
+
customer: { id: 'client-01', name: 'Acme Property Management' } } } ]
|
|
485
|
+
|
|
486
|
+
account.jobs.past.first.lines.map(&:name) # => ['Bathroom Faucet Installation', 'Trip fee']
|
|
353
487
|
```
|
|
354
488
|
|
|
355
|
-
|
|
489
|
+
### Visits
|
|
490
|
+
|
|
491
|
+
Mock the visits the account has, dated by `starts_at`:
|
|
356
492
|
|
|
357
493
|
```ruby
|
|
358
|
-
Jbr.mock.
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
# => '3 Bathroom Faucet Installation and 2 Change Toilet Valve'
|
|
494
|
+
Jbr.mock.visits = [ { id: 'visit-01', description: 'Furnace tune-up',
|
|
495
|
+
location: { id: 'property-01', street: '1 Main St',
|
|
496
|
+
customer: { id: 'client-01', name: 'Jane' } },
|
|
497
|
+
starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
|
|
498
|
+
anytime: false, confirmed: true,
|
|
499
|
+
technicians: [ { id: 'user-01', name: 'Grace', surname: 'Hopper' } ] } ]
|
|
365
500
|
```
|
|
366
501
|
|
|
367
|
-
###
|
|
502
|
+
### Technicians
|
|
368
503
|
|
|
369
|
-
Mock the
|
|
504
|
+
Mock the crew the account has:
|
|
370
505
|
|
|
371
506
|
```ruby
|
|
372
|
-
Jbr.mock.
|
|
373
|
-
property: { id: 'property-01', street: '1 Main St',
|
|
374
|
-
client: { id: 'client-01', first_name: 'Jane' } },
|
|
375
|
-
client: { id: 'client-01', first_name: 'Jane' },
|
|
376
|
-
starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
|
|
377
|
-
all_day: false, client_confirmed: true } ]
|
|
507
|
+
Jbr.mock.technicians = [ { id: 'user-01', name: 'Grace', surname: 'Hopper' } ]
|
|
378
508
|
```
|
|
379
509
|
|
|
510
|
+
A mocked `visits.create` reaches nobody and answers the hour and the crew it was handed, with
|
|
511
|
+
`Jbr.mock.lead` for the lead it hangs off. It refuses a moment naming no zone exactly as Jobber
|
|
512
|
+
does, so a suite cannot pass on a booking that could not be made.
|
|
513
|
+
|
|
380
514
|
### Invoices
|
|
381
515
|
|
|
382
516
|
Mock successfully fetching an invoice:
|
|
383
517
|
|
|
384
518
|
```ruby
|
|
385
|
-
Jbr.mock.invoice = { id: 'invoice-01',
|
|
519
|
+
Jbr.mock.invoice = { id: 'invoice-01', job: { id: 'job-01' }, amount: 19.99,
|
|
520
|
+
issued_at: Date.yesterday.noon }
|
|
386
521
|
```
|