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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +139 -0
  4. data/README.md +302 -167
  5. data/lib/graphql/client.rb +2 -6
  6. data/lib/graphql/error.rb +10 -0
  7. data/lib/graphql/throttled.rb +0 -9
  8. data/lib/jbr/account.rb +48 -15
  9. data/lib/jbr/authorizing.rb +43 -0
  10. data/lib/jbr/booking.rb +56 -0
  11. data/lib/jbr/collection.rb +7 -0
  12. data/lib/jbr/collections/customers.rb +54 -0
  13. data/lib/jbr/collections/invoices.rb +18 -0
  14. data/lib/jbr/{jobs.rb → collections/jobs.rb} +8 -8
  15. data/lib/jbr/collections/leads.rb +30 -0
  16. data/lib/jbr/collections/locations.rb +47 -0
  17. data/lib/jbr/collections/quotes.rb +18 -0
  18. data/lib/jbr/collections/technicians.rb +17 -0
  19. data/lib/jbr/collections/visits.rb +61 -0
  20. data/lib/jbr/error.rb +1 -1
  21. data/lib/jbr/errors/throttled.rb +6 -0
  22. data/lib/jbr/includable.rb +10 -8
  23. data/lib/jbr/listable.rb +17 -35
  24. data/lib/jbr/logger.rb +13 -0
  25. data/lib/jbr/mock/account.rb +28 -7
  26. data/lib/jbr/mock/invoice.rb +7 -11
  27. data/lib/jbr/mock/invoices.rb +7 -0
  28. data/lib/jbr/mock/jobs.rb +10 -6
  29. data/lib/jbr/mock/leads.rb +7 -0
  30. data/lib/jbr/mock/quote.rb +3 -8
  31. data/lib/jbr/mock/quotes.rb +7 -0
  32. data/lib/jbr/mock/technicians.rb +12 -0
  33. data/lib/jbr/mock/visit.rb +9 -16
  34. data/lib/jbr/mock/visits.rb +39 -6
  35. data/lib/jbr/mock.rb +17 -4
  36. data/lib/jbr/phone.rb +5 -8
  37. data/lib/jbr/querying.rb +68 -0
  38. data/lib/jbr/reader.rb +9 -0
  39. data/lib/jbr/reading.rb +15 -0
  40. data/lib/jbr/refreshing.rb +0 -8
  41. data/lib/jbr/resources/customer.rb +23 -0
  42. data/lib/jbr/resources/invoice.rb +29 -0
  43. data/lib/jbr/resources/job.rb +20 -0
  44. data/lib/jbr/resources/lead.rb +7 -0
  45. data/lib/jbr/resources/line.rb +15 -0
  46. data/lib/jbr/resources/location.rb +31 -0
  47. data/lib/jbr/resources/quote.rb +15 -0
  48. data/lib/jbr/resources/technician.rb +19 -0
  49. data/lib/jbr/resources/visit.rb +30 -0
  50. data/lib/jbr/scheduled.rb +36 -0
  51. data/lib/jbr/version.rb +1 -1
  52. data/lib/jbr.rb +45 -45
  53. metadata +46 -28
  54. data/lib/jbr/client.rb +0 -98
  55. data/lib/jbr/cliental.rb +0 -14
  56. data/lib/jbr/invoice.rb +0 -43
  57. data/lib/jbr/itemized.rb +0 -9
  58. data/lib/jbr/job.rb +0 -40
  59. data/lib/jbr/line_item.rb +0 -44
  60. data/lib/jbr/mock/client.rb +0 -17
  61. data/lib/jbr/mock/job.rb +0 -31
  62. data/lib/jbr/mock/line_item.rb +0 -16
  63. data/lib/jbr/mock/oauth.rb +0 -22
  64. data/lib/jbr/mock/property.rb +0 -19
  65. data/lib/jbr/mock/request.rb +0 -12
  66. data/lib/jbr/mock/url.rb +0 -9
  67. data/lib/jbr/mocking.rb +0 -30
  68. data/lib/jbr/named.rb +0 -9
  69. data/lib/jbr/oauth.rb +0 -94
  70. data/lib/jbr/properted.rb +0 -17
  71. data/lib/jbr/property.rb +0 -93
  72. data/lib/jbr/quote.rb +0 -25
  73. data/lib/jbr/request.rb +0 -37
  74. data/lib/jbr/resource.rb +0 -26
  75. data/lib/jbr/retriable.rb +0 -20
  76. data/lib/jbr/url.rb +0 -16
  77. data/lib/jbr/visit.rb +0 -24
  78. data/lib/jbr/visits.rb +0 -36
  79. /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. It needs the standard library and two files of Active
4
- Support, to tell a field Jobber answered empty from one it never answered at all.
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.oauth_url_for redirect_uri:, state:
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 credentials with a code and a redirect URI:
32
+ Create an account with the code Jobber sends back and the URI it sent it to:
18
33
 
19
34
  ```ruby
20
- oauth = Jbr.create_oauth code:, redirect_uri:
35
+ account = Jbr::Account.create code:, redirect_uri:
21
36
  ```
22
37
 
23
- Initialize with existing credentials:
38
+ Open an account with credentials already on file:
24
39
 
25
40
  ```ruby
26
- oauth = Jbr.oauth_for access_token:, refresh_token:, expires_at:, account_id:
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 a queue of workers, each
30
- building its own hand over a `store:` as well, and only one of them will ever spend the
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
- oauth = Jbr.oauth_for account_id:, invalid_at:, store: credentials
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 oauth_params } # Active Record, in an app that has it
56
+ def exclusively = with_lock { yield tokens } # Active Record, in an app that has it
43
57
 
44
- def write(oauth) = update oauth: oauth
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 where without a
49
- store each of them would spend a refresh token the first has already spent, and Jobber would
50
- call every one of those a dead grant.
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
- Access OAuth attributes:
69
+ Read the credentials as Jobber last gave them:
56
70
 
57
71
  ```ruby
58
- oauth.access_token # => 'eyJhbGciOiJIUzI1NiJ'
59
- oauth.refresh_token # => 'ea02775958c5fca28d'
60
- oauth.expires_at # => 2026-05-22 14:32:53
61
- oauth.account_id # => 'Z2lkOi8vSm9iYmV'
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 account they belong to, once, however much of it is asked for:
78
+ Read the business they belong to:
65
79
 
66
80
  ```ruby
67
- account = oauth.account
68
- account.id # => 'Z2lkOi8vSm9iYmV'
69
- account.name # => 'Acme Plumbing'
70
- account.phone # => '(704) 459-7540', as Jobber holds it
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
- oauth.delete
90
+ account.delete
77
91
  ```
78
92
 
79
- Credentials go bad only when Jobber says so of the grant itself `The provided refresh token
80
- is not valid.` which sets `invalid_at` and answers queries with nothing. Anything else that
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
- oauth.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
87
- oauth.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jobber had trouble
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
- ### Requests
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
- Create a Jobber request, finding or creating a Client with a matching phone number:
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
- request = oauth.requests.create first_name: 'Jane', last_name: 'Doe', phone: '5553335555',
96
- email: 'jane@example.com', title: 'New Plumber Lead', instructions: 'Needs new faucet'
97
- request.id # => 'Z2lkOi8vSm9iYmVyL'
98
- request.client_id # => 'MwMTU0Mg'
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 = oauth.quotes.find 'Z2lkOi8vS'
173
+ quote = account.quotes.find 'Z2lkOi8vS'
107
174
  quote.id # => 'Z2lkOi8vS'
108
- quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
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 = oauth.jobs.find 'Njc5MTk5'
183
+ job = account.jobs.find 'Njc5MTk5'
117
184
  job.id # => 'Z2lkOi8vS'
118
- job.quote_id # => 'Z2lkOi8vS'
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 a duration, measured from the same now the half is split at and a walk that
125
- stops at a boundary reads only the pages up to it. Jobber is asked for a page at a time, and
126
- only once the page before it runs out, so `first` costs one request where `to_a` costs as many
127
- as the account has pages. A walk is priced by what its pages carry, and a long one can be
128
- refused for it: see [Rate limits](#rate-limits).
129
-
130
- ```ruby
131
- jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
132
- oauth.jobs.past # => the ones dated before now, nothing fetched yet
133
- oauth.jobs.upcoming # => the ones dated from now on, nothing fetched yet
134
- oauth.jobs.past(1.year) # => only as far back as a year, which is fewer pages to read
135
- oauth.jobs.past.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else about them
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
- ### Line items
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 = oauth.jobs.includes(:line_items).find 'Njc5MTk5'
215
+ job = account.jobs.includes(:lines).find 'Njc5MTk5'
154
216
 
155
- job.summary # => '3 Bathroom Faucet Installation and 2 Change Toilet Valve', the lines as a
156
- # sentence of how many of what. Falls back to #name where there are none
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.line_items # => an Array of the lines the job is made of
159
- line = job.line_items.first
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 -- what Jobber calls totalPrice
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 and
171
- whatever each is quantified at. One it holds no quantity for reads as its name alone. Ask for
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 = oauth.invoices.find 'MjU3ODA0'
238
+ invoice = account.invoices.find 'MjU3ODA0'
181
239
  invoice.id # => 'MjU3ODA0'
182
- invoice.job_id # => 'Z2lkOi8vS'
183
- invoice.total # => '40.30'
184
- invoice.issued_at # => 2026-05-22 12:12:53
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
- Walk the account's visits, oldest first, the same way as its jobs:
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 = oauth.visits # => an Enumerable of every visit, nothing fetched yet
194
- oauth.visits.upcoming # => the ones dated from now on, nothing fetched yet
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.name # => 'Furnace tune-up', or the visit's ID where nobody titled it. Never nil or empty
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.all_day? # => false
207
- visit.client_confirmed? # => true
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
- ### Clients and properties
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 visits or on jobs:
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
- visit = oauth.visits.includes(:client, property: :client).upcoming.first
358
+ job = account.jobs.includes(location: :customer).find 'Z2lkOi8vSm9i'
217
359
 
218
- visit.client.name # => 'Jane', or the business's name where the client is a business.
219
- # Never an empty string: a blank first name falls through to the company
220
- visit.client.first_name # => 'Jane'
221
- visit.client.last_name # => 'Doe'
222
- visit.client.company_name # => nil
223
- visit.client.email # => 'jane@example.com'
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
- visit.property.id # => 'Z2lkOi8vS'
227
- visit.property.street # => '1 Main St'
228
- visit.property.city # => 'Raleigh'
229
- visit.property.zip # => '27601'
230
- visit.property.latitude # => 35.77
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: `oauth.visits.first.client.name` is nil where the
238
- query never named a client.
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 it never sleeps, and it never asks a second time. What it does is say exactly
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::Retriable`, a `Jbr::Error` for a refusal worth asking again, carrying the
252
- numbers to decide with:
253
-
254
- ```ruby
255
- error.cost # => 1885, what the query was priced at
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 visits to a page.
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 so five times the
272
- account is read for a fraction of the budget. Reach for it where each record is then read on
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
- oauth.jobs.past.ids.each { |id| ImportJob.perform_later id }
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
- Use these methods to mock request to Jobber when testing an app:
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 redirect URL:
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 account the credentials belong to. Left unset, its ID is `account-01`:
444
+ Mock the business the credentials belong to. Left unset, its ID is `account-01`:
315
445
 
316
446
  ```ruby
317
- Jbr.mock.account = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
447
+ Jbr.mock.business = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
318
448
  ```
319
449
 
320
- ### Requests
450
+ ### Leads
321
451
 
322
- Mock successfully creating a request:
452
+ Mock successfully filing a lead:
323
453
 
324
454
  ```ruby
325
- Jbr.mock.request = { id: 'request-01', client_id: 'client-01' }
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', request_id: 'request-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', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }
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', title: 'Furnace tune-up', status: 'archived',
349
- total: 260.0, quote_total: 240.0, created_at: Date.yesterday.noon,
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
- property: { id: 'property-01', street: '1 Main St',
352
- client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
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
- Mock the lines a job is made of, under the job that is made of them:
489
+ ### Visits
490
+
491
+ Mock the visits the account has, dated by `starts_at`:
356
492
 
357
493
  ```ruby
358
- Jbr.mock.jobs = [ { id: 'job-01', line_items: [
359
- { quantity: 3.0, name: 'Bathroom Faucet Installation',
360
- description: 'Professional installation of a new bathroom faucet' },
361
- { quantity: 2.0, name: 'Change Toilet Valve' } ] } ]
362
-
363
- oauth.jobs.past.first.summary
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
- ### Visits
502
+ ### Technicians
368
503
 
369
- Mock the visits the account has:
504
+ Mock the crew the account has:
370
505
 
371
506
  ```ruby
372
- Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01',
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', job_id: 'job-01', total: 19.99, issued_at: Date.yesterday.noon }
519
+ Jbr.mock.invoice = { id: 'invoice-01', job: { id: 'job-01' }, amount: 19.99,
520
+ issued_at: Date.yesterday.noon }
386
521
  ```