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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +59 -0
  4. data/README.md +155 -165
  5. data/lib/graphql/client.rb +2 -6
  6. data/lib/graphql/throttled.rb +0 -9
  7. data/lib/jbr/account.rb +44 -15
  8. data/lib/jbr/authorizing.rb +43 -0
  9. data/lib/jbr/collection.rb +14 -0
  10. data/lib/jbr/collections/customers.rb +54 -0
  11. data/lib/jbr/collections/invoices.rb +18 -0
  12. data/lib/jbr/{jobs.rb → collections/jobs.rb} +6 -6
  13. data/lib/jbr/collections/leads.rb +30 -0
  14. data/lib/jbr/collections/locations.rb +47 -0
  15. data/lib/jbr/collections/quotes.rb +18 -0
  16. data/lib/jbr/{visits.rb → collections/visits.rb} +8 -8
  17. data/lib/jbr/error.rb +1 -1
  18. data/lib/jbr/errors/throttled.rb +6 -0
  19. data/lib/jbr/includable.rb +6 -8
  20. data/lib/jbr/listable.rb +12 -34
  21. data/lib/jbr/mock/account.rb +25 -7
  22. data/lib/jbr/mock/invoice.rb +7 -11
  23. data/lib/jbr/mock/invoices.rb +7 -0
  24. data/lib/jbr/mock/jobs.rb +10 -6
  25. data/lib/jbr/mock/leads.rb +7 -0
  26. data/lib/jbr/mock/quote.rb +3 -8
  27. data/lib/jbr/mock/quotes.rb +7 -0
  28. data/lib/jbr/mock/visit.rb +3 -19
  29. data/lib/jbr/mock/visits.rb +9 -5
  30. data/lib/jbr/mock.rb +16 -3
  31. data/lib/jbr/phone.rb +5 -8
  32. data/lib/jbr/querying.rb +57 -0
  33. data/lib/jbr/reader.rb +9 -0
  34. data/lib/jbr/refreshing.rb +0 -8
  35. data/lib/jbr/resources/customer.rb +23 -0
  36. data/lib/jbr/resources/invoice.rb +29 -0
  37. data/lib/jbr/resources/job.rb +20 -0
  38. data/lib/jbr/resources/lead.rb +7 -0
  39. data/lib/jbr/resources/line.rb +15 -0
  40. data/lib/jbr/resources/location.rb +31 -0
  41. data/lib/jbr/resources/quote.rb +15 -0
  42. data/lib/jbr/resources/visit.rb +17 -0
  43. data/lib/jbr/version.rb +1 -1
  44. data/lib/jbr.rb +37 -46
  45. metadata +39 -28
  46. data/lib/jbr/client.rb +0 -98
  47. data/lib/jbr/cliental.rb +0 -14
  48. data/lib/jbr/invoice.rb +0 -43
  49. data/lib/jbr/itemized.rb +0 -9
  50. data/lib/jbr/job.rb +0 -40
  51. data/lib/jbr/line_item.rb +0 -36
  52. data/lib/jbr/mock/client.rb +0 -17
  53. data/lib/jbr/mock/job.rb +0 -31
  54. data/lib/jbr/mock/line_item.rb +0 -10
  55. data/lib/jbr/mock/oauth.rb +0 -22
  56. data/lib/jbr/mock/property.rb +0 -19
  57. data/lib/jbr/mock/request.rb +0 -12
  58. data/lib/jbr/mock/url.rb +0 -9
  59. data/lib/jbr/mocking.rb +0 -30
  60. data/lib/jbr/named.rb +0 -9
  61. data/lib/jbr/oauth.rb +0 -94
  62. data/lib/jbr/properted.rb +0 -17
  63. data/lib/jbr/property.rb +0 -93
  64. data/lib/jbr/quote.rb +0 -25
  65. data/lib/jbr/request.rb +0 -37
  66. data/lib/jbr/resource.rb +0 -26
  67. data/lib/jbr/retriable.rb +0 -20
  68. data/lib/jbr/url.rb +0 -16
  69. data/lib/jbr/visit.rb +0 -24
  70. /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: c97671522675aa1f7ae29743d5edb7188a49251adb4d485c50b144bfa64f27cf
4
- data.tar.gz: 12c24e22c88f4880ed86c6b8377b34dafda2939e6ffd46b95a2f329cdcbc8c03
3
+ metadata.gz: 955de83498d3d68359199efa3e7b2d54bef89040000e80085969f4577e14d80d
4
+ data.tar.gz: 925c1785457c527ce3ad9dfe677a386327e70155f9895cccca6b51c278be758f
5
5
  SHA512:
6
- metadata.gz: 814518f518e0a4fb14f738462991b675c2f249f88e652fa76cb36e4685d00fd393250d8b4ec12256541ddd5318ecb30b23c712384fda2a6ee588b0ff2c2ba1fb
7
- data.tar.gz: 9d8755f3c9ccab26b72f18d76d465f23fd949bf36b2f5d2dfe2a3a96d6f99d500c5e8fefb9ab019044205934dd0eb6dd1111762853087a9605e40257e13490f5
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. 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', '~> 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.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,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 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
88
102
  ```
89
103
 
90
- ### Requests
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
- Create a Jobber request, finding or creating a Client with a matching phone number:
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
- 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'
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 = oauth.quotes.find 'Z2lkOi8vS'
129
+ quote = account.quotes.find 'Z2lkOi8vS'
107
130
  quote.id # => 'Z2lkOi8vS'
108
- quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
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 = oauth.jobs.find 'Njc5MTk5'
139
+ job = account.jobs.find 'Njc5MTk5'
117
140
  job.id # => 'Z2lkOi8vS'
118
- job.quote_id # => 'Z2lkOi8vS'
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 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).
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
- 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
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
- ### Line items
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 = oauth.jobs.includes(:line_items).find 'Njc5MTk5'
171
+ job = account.jobs.includes(:lines).find 'Njc5MTk5'
154
172
 
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
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.line_items # => an Array of the lines the job is made of
159
- line = job.line_items.first
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.to_s # => '3 Bathroom Faucet Installation', how many of what, and the name alone where
164
- # Jobber holds no quantity for the line
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 and
168
- whatever each is quantified at. One it holds no quantity for reads as its name alone. Ask for
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 = oauth.invoices.find 'MjU3ODA0'
194
+ invoice = account.invoices.find 'MjU3ODA0'
178
195
  invoice.id # => 'MjU3ODA0'
179
- invoice.job_id # => 'Z2lkOi8vS'
180
- invoice.total # => '40.30'
181
- invoice.issued_at # => 2026-05-22 12:12:53
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 = oauth.visits # => an Enumerable of every visit, nothing fetched yet
191
- oauth.visits.upcoming # => the ones dated from now on, nothing fetched yet
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.first
209
+ visit = account.visits.find 'Z2lkOi8vS'
197
210
  visit.id # => 'Z2lkOi8vS'
198
- visit.name # => 'Furnace tune-up', or the visit's ID where nobody titled it. Never nil or empty
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.all_day? # => false
204
- visit.client_confirmed? # => true
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
- ### Clients and properties
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 visits or on jobs:
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
- visit = oauth.visits.includes(:client, property: :client).upcoming.first
214
-
215
- visit.client.name # => 'Jane', or the business's name where the client is a business.
216
- # Never an empty string: a blank first name falls through to the company
217
- visit.client.first_name # => 'Jane'
218
- visit.client.last_name # => 'Doe'
219
- visit.client.company_name # => nil
220
- visit.client.email # => 'jane@example.com'
221
- visit.client.phone # => '5553335555', the reachable North American number, or nil
222
-
223
- visit.property.id # => 'Z2lkOi8vS'
224
- visit.property.street # => '1 Main St'
225
- visit.property.city # => 'Raleigh'
226
- visit.property.zip # => '27601'
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: `oauth.visits.first.client.name` is nil where the
235
- query never named a client.
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 it never sleeps, and it never asks a second time. What it does is say exactly
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::Retriable`, a `Jbr::Error` for a refusal worth asking again, carrying the
249
- numbers to decide with:
250
-
251
- ```ruby
252
- error.cost # => 1885, what the query was priced at
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 so five times the
269
- account is read for a fraction of the budget. Reach for it where each record is then read on
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
- oauth.jobs.past.ids.each { |id| ImportJob.perform_later id }
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
- Use these methods to mock request to Jobber when testing an app:
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 redirect URL:
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 account the credentials belong to. Left unset, its ID is `account-01`:
309
+ Mock the business the credentials belong to. Left unset, its ID is `account-01`:
312
310
 
313
311
  ```ruby
314
- Jbr.mock.account = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
312
+ Jbr.mock.business = { id: 'account-01', name: 'Acme Plumbing', phone: '(704) 459-7540' }
315
313
  ```
316
314
 
317
- ### Requests
315
+ ### Leads
318
316
 
319
- Mock successfully creating a request:
317
+ Mock successfully filing a lead:
320
318
 
321
319
  ```ruby
322
- Jbr.mock.request = { id: 'request-01', client_id: 'client-01' }
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', request_id: 'request-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', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }
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', title: 'Furnace tune-up', status: 'archived',
346
- total: 260.0, quote_total: 240.0, created_at: Date.yesterday.noon,
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
- property: { id: 'property-01', street: '1 Main St',
349
- client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
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
- oauth.jobs.past.first.summary
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', title: 'Furnace tune-up', job_id: 'job-01',
370
- property: { id: 'property-01', street: '1 Main St',
371
- client: { id: 'client-01', first_name: 'Jane' } },
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
- all_day: false, client_confirmed: true } ]
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', job_id: 'job-01', total: 19.99, issued_at: Date.yesterday.noon }
371
+ Jbr.mock.invoice = { id: 'invoice-01', job: { id: 'job-01' }, amount: 19.99,
372
+ issued_at: Date.yesterday.noon }
383
373
  ```
@@ -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.new(refusal(body), cost) : Error.new(refusal(body))
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