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