hcp 1.4.0 → 3.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/.yardopts +0 -1
- data/CHANGELOG.md +75 -0
- data/CLAUDE.md +34 -9
- data/README.md +99 -125
- data/lib/hcp/account.rb +25 -0
- data/lib/hcp/answer.rb +3 -11
- data/lib/hcp/client.rb +41 -0
- data/lib/hcp/collections/estimates.rb +22 -0
- data/lib/hcp/collections/jobs.rb +32 -0
- data/lib/hcp/collections/leads.rb +25 -0
- data/lib/hcp/collections/technicians.rb +23 -0
- data/lib/hcp/collections/visits.rb +80 -0
- data/lib/hcp/error.rb +1 -1
- data/lib/hcp/errors/throttled.rb +4 -0
- data/lib/hcp/event.rb +1 -6
- data/lib/hcp/resources/business.rb +11 -0
- data/lib/hcp/resources/customer.rb +9 -45
- data/lib/hcp/resources/estimate.rb +40 -46
- data/lib/hcp/resources/job.rb +58 -69
- data/lib/hcp/resources/lead.rb +26 -0
- data/lib/hcp/resources/line.rb +8 -0
- data/lib/hcp/resources/location.rb +7 -0
- data/lib/hcp/resources/quote.rb +28 -0
- data/lib/hcp/resources/technician.rb +7 -0
- data/lib/hcp/resources/visit.rb +45 -0
- data/lib/hcp/version.rb +3 -3
- data/lib/hcp.rb +22 -41
- metadata +30 -28
- data/lib/hcp/access.rb +0 -12
- data/lib/hcp/concerns/chainable.rb +0 -39
- data/lib/hcp/concerns/keyed.rb +0 -14
- data/lib/hcp/concerns/named.rb +0 -7
- data/lib/hcp/concerns/queryable.rb +0 -41
- data/lib/hcp/concerns/scheduled.rb +0 -16
- data/lib/hcp/concerns/statused.rb +0 -23
- data/lib/hcp/concerns/timestamped.rb +0 -10
- data/lib/hcp/errors/not_found.rb +0 -4
- data/lib/hcp/errors/too_many_requests.rb +0 -16
- data/lib/hcp/filter.rb +0 -36
- data/lib/hcp/key.rb +0 -24
- data/lib/hcp/lead/pipeline.rb +0 -36
- data/lib/hcp/lead.rb +0 -44
- data/lib/hcp/relation.rb +0 -76
- data/lib/hcp/request.rb +0 -31
- data/lib/hcp/resource.rb +0 -58
- data/lib/hcp/resources/address.rb +0 -24
- data/lib/hcp/resources/booking_window.rb +0 -52
- data/lib/hcp/resources/company.rb +0 -42
- data/lib/hcp/resources/employee.rb +0 -12
- data/lib/hcp/resources/estimate/option.rb +0 -26
- data/lib/hcp/resources/job/appointment.rb +0 -22
- data/lib/hcp/resources/job/invoice.rb +0 -21
- data/lib/hcp/resources/line_item.rb +0 -20
- data/lib/hcp/resources/note.rb +0 -6
- data/lib/hcp/resources/schedule.rb +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 796d536211d1039f154af0ea0340de4e814d9cf86c9009e60af68670dbc16cda
|
|
4
|
+
data.tar.gz: cd18b17cc8e162a750c998f5d554835d6982d75a7b417c4d60dc26dcfda9106a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a6302bcfa3722c31caae3cc45d2bfb7d30c60895869c6a03fcc8d7851db99be8ad7f1b460d704f51a268e11fe2a78fef2e351186f9617f312d0ae759d0043e2
|
|
7
|
+
data.tar.gz: 65d2d60980d7386121a6f411f591398741fda04298a42d39102666b530ebd5239c4cfbe4297ea98d9d99721c2719505674440360551269352a52e0f2d69fceb3
|
data/.yardopts
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [3.0.0] - 2026-09-15
|
|
4
|
+
|
|
5
|
+
- [Breaking change] The vocabulary is `company` 2.1: a `Company::Selection` takes its rule as a
|
|
6
|
+
block, a visit answers a lead as well as a job, and it says where it is. The pin is `~> 2.1`.
|
|
7
|
+
|
|
8
|
+
- [Breaking change] `account.visits` is every stop booked, not only a job's: an estimate is
|
|
9
|
+
work still being looked at, so its slot is a visit too, and the list now reads `/jobs` and
|
|
10
|
+
`/estimates` rather than `/jobs` alone. `visit.job` is nil on an estimate's slot and
|
|
11
|
+
`visit.lead` is the `Hcp::Estimate`; `visit.description` is nil there, Housecall Pro giving
|
|
12
|
+
an estimate no words of its own. A caller that wants what it had before asks
|
|
13
|
+
`account.visits.for_jobs`, which is the one request it always was, and `for_leads` is the
|
|
14
|
+
estimates alone.
|
|
15
|
+
|
|
16
|
+
- [Feature] `Hcp::Estimate`, a `Company::Lead`: `id`, `customer`, `location`, `technicians` and
|
|
17
|
+
the one slot it is booked for. Housecall Pro expands an estimate with `attachments` and not
|
|
18
|
+
with appointments, so the estimate's own `schedule` is the slot and answers to its ID.
|
|
19
|
+
|
|
20
|
+
- [Feature] `Hcp::Visit#location`, where the stop is: an appointment has no address of its own
|
|
21
|
+
and takes the job's, an estimate's slot takes the estimate's. Both nodes are read in full
|
|
22
|
+
already, so it costs nothing and is always there.
|
|
23
|
+
|
|
24
|
+
- [Feature] `Hcp::Visit#lead`, and `technicians` reading an estimate's `assigned_employees`
|
|
25
|
+
where a job's stop reads its dispatch.
|
|
26
|
+
|
|
27
|
+
- [Feature] `account.technicians` walks the location's employees a page at a time, each an
|
|
28
|
+
`Hcp::Technician` reading `id`, `name` off `first_name` and `surname` off `last_name`.
|
|
29
|
+
|
|
30
|
+
- [Feature] `job.technicians` is the crew a job is assigned to, and `visit.technicians` whoever
|
|
31
|
+
an appointment was dispatched to -- or, where it was dispatched to nobody, the job's whole
|
|
32
|
+
crew, which is how Housecall Pro draws it.
|
|
33
|
+
|
|
34
|
+
- [Feature] `account.visits.between(from, to).assigned_to(technician)` is one technician's
|
|
35
|
+
week. The window goes to Housecall Pro as `employee_ids`, so only their jobs come back, and
|
|
36
|
+
the stops of those jobs they are not on are let go as the jobs arrive.
|
|
37
|
+
|
|
38
|
+
## [2.0.0] - 2026-09-09
|
|
39
|
+
|
|
40
|
+
- [Breaking change] The key is one account's: `Hcp::Account.new(key:, company_id:)`, a
|
|
41
|
+
`Company::Account`, answers `business` and `leads`. `Hcp.key`, `Hcp.with_key`, `Hcp::Access`
|
|
42
|
+
and `HCP_KEY` go.
|
|
43
|
+
|
|
44
|
+
- [Breaking change] `Hcp::Company` is `Hcp::Business`, a `Company::Business`: `id`, `name`,
|
|
45
|
+
`phone` as ten digits and `subsidiaries`, which was `locations` and which the vocabulary
|
|
46
|
+
now lists off the locations Housecall Pro nests. `website`, `time_zone`,
|
|
47
|
+
`logo_url`, `support_email`, `arrival_window`, `address` and `zip_codes` go.
|
|
48
|
+
|
|
49
|
+
- [Breaking change] `Hcp::Lead.new(key:, company_id:).create` is `account.leads.create`, which
|
|
50
|
+
takes the vocabulary's words -- `name:`, `surname:`, `phone:`, `email:`, `address:`,
|
|
51
|
+
`description:`, `notes:`, `source:`, the description heading the note -- and answers the lead; `Hcp::Lead::Pipeline` is
|
|
52
|
+
`account.leads.find(id).update(status_name:)`. Writes go through the same plumbing as reads,
|
|
53
|
+
so a refusal is read the three ways Housecall Pro writes one.
|
|
54
|
+
|
|
55
|
+
- [Breaking change] Gone, unread by any caller: `Relation`, `Chainable`, `Queryable`, `Filter`,
|
|
56
|
+
`Job`, `Job::Appointment`, `Job::Invoice`, `Estimate`, `Estimate::Option`, `Customer`,
|
|
57
|
+
`Employee`, `Schedule`, `Note`, `LineItem`, `Address`, `BookingWindow`, `Hcp::NotFound` --
|
|
58
|
+
a 404 raises `Hcp::Error` -- `Hcp::TooManyRequests#reset_at` and `Hcp::Event#type`.
|
|
59
|
+
|
|
60
|
+
- [Breaking change] `Hcp::TooManyRequests` is `Hcp::Throttled`, a `Company::Throttled` rather
|
|
61
|
+
than an `Hcp::Error`, so one rescue retries a refusal for rate from any platform.
|
|
62
|
+
|
|
63
|
+
- [Feature] `account.jobs.past(within)` walks the jobs booked to start in the window a page at a
|
|
64
|
+
time, each an `Hcp::Job` reading in the vocabulary: `scheduled_at` and `completed_at` off the
|
|
65
|
+
nested schedule and timestamps, `notes` listed one to a line,
|
|
66
|
+
`amount` in dollars off the cents, `quote` the estimate option
|
|
67
|
+
the job was created from -- its `amount` the option's total, found once among the customer's
|
|
68
|
+
estimates --
|
|
69
|
+
`location` with its `customer` beside it, and `lines` read once off the job's own endpoint.
|
|
70
|
+
|
|
71
|
+
- [Feature] `account.visits.upcoming(within)` walks the visits booked to start in the window,
|
|
72
|
+
which Housecall Pro calls appointments and files inside jobs: the jobs booked across the
|
|
73
|
+
window are read with their appointments, a canceled job's are skipped, and each `Hcp::Visit`
|
|
74
|
+
answers `starts_at`, `ends_at`, `anytime?`, its job's `description` and the `job` itself.
|
|
75
|
+
|
|
76
|
+
- [Feature] `Hcp::Error` descends from `Company::Error`, so one rescue covers every platform.
|
|
77
|
+
|
|
3
78
|
## [1.4.0] - 2026-08-28
|
|
4
79
|
|
|
5
80
|
- [Feature] Read as a key for one block, on one thread: `Hcp.with_key(key, company_id:)`,
|
data/CLAUDE.md
CHANGED
|
@@ -25,18 +25,43 @@ The authoritative OpenAPI spec is at
|
|
|
25
25
|
docs.housecallpro.com renders it client-side, so fetching that HTML gets nothing worth reading.
|
|
26
26
|
|
|
27
27
|
**The spec disagrees with the live API.** Send a real request before writing code against a
|
|
28
|
-
documented shape.
|
|
28
|
+
documented shape. What has been found so far:
|
|
29
29
|
|
|
30
|
-
-
|
|
30
|
+
- A refusal comes back three ways -- `{"error":{"message":…}}`, `{"error":"…"}` and `{"message":…}`.
|
|
31
|
+
The spec describes only the first.
|
|
32
|
+
- `PUT /pipeline/statuses` is answered with an empty body.
|
|
31
33
|
- `GET /jobs/{id}/line_items` answers `{"object":"list","data":[…]}`, not the documented
|
|
32
34
|
`{url, data}`.
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
`GET /
|
|
35
|
+
- `page_size` is capped at 200. The spec publishes no maximum.
|
|
36
|
+
- A job answers `schedule.appointments` only with `expand[]=appointments`, and `expand` must be
|
|
37
|
+
sent as an array: a bare string is refused. A job's `scheduled_start` and `scheduled_end`
|
|
38
|
+
are computed from its appointments, so a job booked across a window carries every
|
|
39
|
+
appointment in it. An appointment has no address of its own.
|
|
40
|
+
- A job's `original_estimate_id` is the ID of the estimate **option** it was created from, an
|
|
41
|
+
`est_` ID. `GET /estimates/{id}` takes the estimate's own `csr_` ID and answers
|
|
42
|
+
`Estimate not found` for an option's, so an option is found by listing
|
|
43
|
+
`GET /estimates?customer_id=` and searching the options. Probed live 2026-09-09.
|
|
44
|
+
|
|
45
|
+
- `employee_ids` narrows both `/jobs` and `/estimates` by assigned pro, and `/estimates` takes
|
|
46
|
+
the same `scheduled_start_min`/`scheduled_start_max` window as `/jobs`. Over one year on a
|
|
47
|
+
real account: 15 jobs to 5, and 40 estimates to 2. Worth writing down because the spec gives
|
|
48
|
+
the parameter no description at all on `/jobs` -- only the `/estimates` twin documents it.
|
|
49
|
+
`GET /employees` answers `first_name` and `last_name`. Probed live 2026-09-15.
|
|
50
|
+
|
|
51
|
+
Read off the spec but **not probed**, so treat as claims rather than findings:
|
|
52
|
+
|
|
53
|
+
- `GET /estimates`' `expand` enum is `attachments` alone -- there is no `appointments` on an
|
|
54
|
+
estimate, so its `schedule` is the one slot it occupies. Its `work_status` carries `canceled`.
|
|
55
|
+
- `GET /routes` is the only endpoint that groups a day's work, and is unusable as one: it takes
|
|
56
|
+
a single `date` and `per_page` rather than a range, refuses a Company API Key, and answers
|
|
57
|
+
`event_ids` and `estimate_ids` as bare strings, so the times still cost `/events` and
|
|
58
|
+
`/estimates`.
|
|
59
|
+
- `GET /events` -- time blocked out on the calendar -- takes no date and no employee filter at
|
|
60
|
+
all, so a week of it can only be paged in full. Its `recurrence_rule` is not expanded.
|
|
61
|
+
- `POST /estimates` takes `customer_id`, not a customer, so booking one needs `GET /customers`
|
|
62
|
+
(search by `q`) and `POST /customers` first. The arrival window is spelled three ways:
|
|
63
|
+
`arrival_window_in_minutes` on create, `arrival_window_minutes` on an appointment,
|
|
64
|
+
`arrival_window` on a schedule read back.
|
|
40
65
|
|
|
41
66
|
A company-scoped key refuses `X-Company-Id` with a 401 on every endpoint, so the header can only
|
|
42
67
|
be exercised with an application key. `GET /company` answers `locations` only to the latter, and
|
data/README.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# The Housecall Pro API Ruby client
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
and
|
|
6
|
-
|
|
7
|
-
This release reads. Writing — beyond the leads that were here before it — comes next.
|
|
3
|
+
The Housecall Pro API, answered in the vocabulary of the [company](https://github.com/claudiob/company)
|
|
4
|
+
gem: a key opens an account, the account answers the business it belongs to, its leads and its
|
|
5
|
+
jobs, and nothing else. Where a caller reads nothing, this gem has no method.
|
|
8
6
|
|
|
9
7
|
## How to install
|
|
10
8
|
|
|
@@ -15,187 +13,163 @@ gem install hcp
|
|
|
15
13
|
Or, in a Gemfile, pinned to the current major:
|
|
16
14
|
|
|
17
15
|
```ruby
|
|
18
|
-
gem 'hcp', '~>
|
|
16
|
+
gem 'hcp', '~> 3.0'
|
|
19
17
|
```
|
|
20
18
|
|
|
21
19
|
`~> major.minor` means `bundle update` never crosses a breaking change.
|
|
22
20
|
|
|
23
|
-
## The
|
|
21
|
+
## The account
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
An account is a key, acting as one location where the key opens several:
|
|
26
24
|
|
|
27
25
|
```ruby
|
|
28
|
-
Hcp.key
|
|
26
|
+
account = Hcp::Account.new key: 'the-api-key'
|
|
27
|
+
account = Hcp::Account.new key: 'the-api-key', company_id: 'loc_1'
|
|
29
28
|
```
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
```ruby
|
|
35
|
-
Hcp.with_key(key, company_id: location_id) do |access|
|
|
36
|
-
access.account.name # the location, as `Hcp::Company.current` would read it
|
|
37
|
-
Hcp::Job.limit 10 # every read inside the block is this key's
|
|
38
|
-
end
|
|
39
|
-
```
|
|
30
|
+
`company_id:` is sent as `X-Company-Id`. The account a key belongs to refuses the header, so it
|
|
31
|
+
is only sent where a location is named.
|
|
40
32
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
An account with more than one location passes `company_id:` per call, since one process may
|
|
44
|
-
serve several:
|
|
33
|
+
## The business
|
|
45
34
|
|
|
46
35
|
```ruby
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
business = account.business
|
|
37
|
+
business.id, business.name
|
|
38
|
+
business.phone # => '5555550100', ten digits however Housecall Pro wrote them, or nil
|
|
39
|
+
business.subsidiaries # => the business itself first, then every location under it, flat
|
|
49
40
|
```
|
|
50
41
|
|
|
51
|
-
|
|
42
|
+
Housecall Pro answers a franchise as a tree several levels deep; `subsidiaries` reads it flat
|
|
43
|
+
and is never empty, and each `id` in it is what `company_id:` takes.
|
|
52
44
|
|
|
53
|
-
##
|
|
45
|
+
## Leads
|
|
54
46
|
|
|
55
|
-
|
|
56
|
-
records costs one request rather than all of them:
|
|
47
|
+
Opening a lead opens its customer with it, and hands back what Housecall Pro filed both under:
|
|
57
48
|
|
|
58
49
|
```ruby
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Hcp::Job.where(customer_id: id).order(created_at: :asc).limit 10
|
|
65
|
-
Hcp::Job.all.count
|
|
50
|
+
lead = account.leads.create name: 'Ada', surname: 'Lovelace', phone: '5550000001',
|
|
51
|
+
email: 'ada@example.com', description: 'Fix the sink', notes: 'Very interested in buying',
|
|
52
|
+
address: { street: '1 Example Street', city: 'Springfield', state: 'CA', zip: '90210' },
|
|
53
|
+
source: 'The Lead Generator'
|
|
54
|
+
lead.id, lead.customer.id
|
|
66
55
|
```
|
|
67
56
|
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
Moving a lead through the pipeline names the status as the account names it; a name the
|
|
58
|
+
account has no status for raises `Hcp::Error`:
|
|
70
59
|
|
|
71
60
|
```ruby
|
|
72
|
-
|
|
73
|
-
Hcp::Job.where scheduled_at: ..2.days.ago # scheduled_start_max
|
|
74
|
-
Hcp::Job.where scheduled_at: 1.week.ago..Time.now
|
|
61
|
+
account.leads.find('lea_1').update status_name: 'Won'
|
|
75
62
|
```
|
|
76
63
|
|
|
77
|
-
|
|
78
|
-
the narrower's words on both sides: `where(work_status: :in_progress)` and `job.work_status`
|
|
79
|
-
both say `:in_progress`.
|
|
64
|
+
## Jobs
|
|
80
65
|
|
|
81
|
-
|
|
66
|
+
The jobs booked to start within a window are walked a page at a time, each read flat and in
|
|
67
|
+
dollars however Housecall Pro nested it or counted it:
|
|
82
68
|
|
|
83
69
|
```ruby
|
|
84
|
-
|
|
70
|
+
account.jobs.past(4.weeks).each do |job|
|
|
71
|
+
job.id, job.description, job.created_at, job.scheduled_at, job.completed_at
|
|
72
|
+
job.notes # => "- Gate code 1234\n- Dog in the yard", one to a line
|
|
73
|
+
job.amount # => 330.0, dollars as a BigDecimal, where Housecall Pro said 33000
|
|
74
|
+
job.quote # => an Hcp::Quote, the estimate option the job was created from, or nil
|
|
75
|
+
job.quote.amount # => that option's total in dollars, found among the customer's estimates
|
|
76
|
+
job.location # => an Hcp::Location, or nil where the job is booked nowhere
|
|
77
|
+
job.location.customer # => an Hcp::Customer: id, name, surname, email, phone
|
|
78
|
+
job.lines # => Hcp::Line, read off the job's own line_items endpoint on first ask
|
|
79
|
+
end
|
|
85
80
|
```
|
|
86
81
|
|
|
87
|
-
A
|
|
88
|
-
|
|
82
|
+
A customer's `name` is their first name, or the business's where a person has none, and their
|
|
83
|
+
`phone` is the first of the mobile, home and work numbers that can be dialed.
|
|
89
84
|
|
|
90
|
-
|
|
91
|
-
Hcp::Job.where bogus: 1
|
|
92
|
-
# => Hcp::Error: bogus is not one of: scheduled_at, ends_at, customer_id, ...
|
|
93
|
-
```
|
|
85
|
+
## Visits
|
|
94
86
|
|
|
95
|
-
|
|
87
|
+
A visit is any booked time, and Housecall Pro books it two ways. Work already won is a job,
|
|
88
|
+
and Housecall Pro calls its stops appointments and files them inside it. Work still being
|
|
89
|
+
looked at is an estimate, which it schedules the same way but hangs no appointments under, so
|
|
90
|
+
an estimate holds the one slot. Both are read off the work booked across the window, a page of
|
|
91
|
+
it at a time; work called off keeps its stops to itself, and a list nothing narrows walks
|
|
92
|
+
every job and estimate there was.
|
|
96
93
|
|
|
97
94
|
```ruby
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
job
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
estimate = Hcp::Estimate.find id
|
|
108
|
-
estimate.estimate_number, estimate.options.map(&:total_amount)
|
|
95
|
+
account.visits.upcoming(2.weeks).each do |visit|
|
|
96
|
+
visit.id, visit.starts_at, visit.ends_at, visit.anytime?
|
|
97
|
+
visit.description # => what the job is called, or nil: an estimate has no words of its own
|
|
98
|
+
visit.location # => where the stop is, whatever it was booked for
|
|
99
|
+
visit.job # => the Hcp::Job the stop belongs to, or nil where an estimate does
|
|
100
|
+
visit.lead # => the Hcp::Estimate it belongs to, or nil where a job does
|
|
101
|
+
visit.technicians # => the Hcp::Technicians the stop is booked for
|
|
102
|
+
end
|
|
109
103
|
```
|
|
110
104
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
## The company
|
|
114
|
-
|
|
115
|
-
The account the key belongs to. There is no list of companies to narrow and no ID to look one
|
|
116
|
-
up by — a key reads its own account and nothing else — so it is read with `current` rather than
|
|
117
|
-
with `find` or `where`:
|
|
105
|
+
The two cost a list each, so a caller that wants one kind asks for it and spends one request:
|
|
118
106
|
|
|
119
107
|
```ruby
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
company.time_zone, company.arrival_window, company.address.city, company.zip_codes
|
|
108
|
+
account.visits.upcoming(2.weeks).for_jobs # => only the appointments, one request
|
|
109
|
+
account.visits.upcoming(2.weeks).for_leads # => only the estimates' slots, one request
|
|
123
110
|
```
|
|
124
111
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`locations` is never empty: it is the account itself, then every location under it at any
|
|
129
|
-
depth, in the order Housecall Pro lists them. A single company is its own one location, and a
|
|
130
|
-
franchise reads flat however deep Housecall Pro nests it:
|
|
131
|
-
|
|
132
|
-
```ruby
|
|
133
|
-
Hcp::Company.current.locations.map(&:name)
|
|
134
|
-
# => ['Acme Handyman HQ', 'Acme Handyman East', 'Acme Handyman of Springfield', …]
|
|
135
|
-
```
|
|
112
|
+
A stop says where it is without being asked what it was booked for: an appointment has no
|
|
113
|
+
address of its own and takes the job's, and an estimate's slot takes the estimate's. A caller
|
|
114
|
+
reading a schedule never reaches through `visit.job` for an address.
|
|
136
115
|
|
|
137
|
-
|
|
116
|
+
An estimate reads as the lead it is -- `id`, `customer`, `location` -- because its other half,
|
|
117
|
+
the price, is already `Hcp::Quote`: Housecall Pro files the visit and the prices as one record
|
|
118
|
+
and the vocabulary reads them as two.
|
|
138
119
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
120
|
+
`account.visits.create` is named by the vocabulary and not answered here yet; it raises
|
|
121
|
+
`NotImplementedError`. Booking one means `POST /estimates`, which takes a `customer_id` rather
|
|
122
|
+
than a customer, so it needs `GET /customers` and `POST /customers` first, and all three want
|
|
123
|
+
probing before they are written against.
|
|
142
124
|
|
|
143
|
-
##
|
|
125
|
+
## The schedule
|
|
144
126
|
|
|
145
|
-
|
|
146
|
-
Pro hands this list back whole rather than a page at a time, so it comes back as an `Array`
|
|
147
|
-
rather than as a relation — there is nothing left to narrow, order or cut afterwards.
|
|
127
|
+
The crew are Housecall Pro's employees, and the active ones are walked a page at a time:
|
|
148
128
|
|
|
149
129
|
```ruby
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Hcp::BookingWindow.all service_id: id, minutes: 90
|
|
130
|
+
account.technicians.each do |technician|
|
|
131
|
+
technician.id, technician.name, technician.surname
|
|
132
|
+
end
|
|
154
133
|
```
|
|
155
134
|
|
|
156
|
-
|
|
135
|
+
One technician's week is the visits in it narrowed to them, which is how a schedule reads:
|
|
157
136
|
|
|
158
137
|
```ruby
|
|
159
|
-
|
|
160
|
-
|
|
138
|
+
monday = Date.today.beginning_of_week.in_time_zone
|
|
139
|
+
account.visits.between(monday, monday + 1.week).assigned_to(technician).each do |visit|
|
|
140
|
+
visit.starts_at, visit.ends_at, visit.job.location.street
|
|
141
|
+
end
|
|
161
142
|
```
|
|
162
143
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
144
|
+
Housecall Pro narrows both lists by who is assigned to the work, so the window is asked for as
|
|
145
|
+
that technician's and nobody else's comes back -- verified against a real account, where a year
|
|
146
|
+
of work went from 15 jobs to 5 and from 40 estimates to 2. It narrows no further: a job's appointments are
|
|
147
|
+
dispatched to some of its crew or to none of it, so a stop dispatched to nobody is the whole
|
|
148
|
+
crew's, and the stops the technician is not on are let go once the jobs arrive. Asking for the
|
|
149
|
+
week and asking for the technician narrow the same list, in either order.
|
|
166
150
|
|
|
167
|
-
|
|
151
|
+
What Housecall Pro schedules elsewhere is still not here: time blocked out on the calendar is
|
|
152
|
+
filed under `/events`, which takes no date and no employee to narrow by, so a week of it cannot
|
|
153
|
+
be asked for -- only paged in full.
|
|
168
154
|
|
|
169
|
-
|
|
155
|
+
## Errors
|
|
170
156
|
|
|
171
|
-
|
|
172
|
-
Hcp::
|
|
173
|
-
|
|
174
|
-
```
|
|
157
|
+
Everything descends from `Hcp::Error`, which descends from `Company::Error`, so one rescue
|
|
158
|
+
still catches the lot. `Hcp::Throttled`, a `Company::Throttled`, is a refusal for rate, so one
|
|
159
|
+
retry covers every platform.
|
|
175
160
|
|
|
176
161
|
Nothing here sleeps. A caller told to come back later has a queue that can bring the whole job
|
|
177
162
|
back, which is worth more than a worker asleep holding a connection open.
|
|
178
163
|
|
|
179
|
-
## Leads
|
|
180
|
-
|
|
181
|
-
Opening a lead, and moving one through the pipeline, are unchanged:
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
lead = Hcp::Lead.new key:, company_id:
|
|
185
|
-
lead.create name: 'Ada', phone: '5550000001', email: 'ada@example.com',
|
|
186
|
-
address: { street: '1 Example Street', city: 'Springfield', state: 'CA', zip: '90210' },
|
|
187
|
-
note: 'Very interested in buying', source: 'The Lead Generator'
|
|
188
|
-
|
|
189
|
-
pipeline = Hcp::Lead::Pipeline.new id:, key:, company_id:
|
|
190
|
-
pipeline.update status_name: 'Won'
|
|
191
|
-
```
|
|
192
|
-
|
|
193
164
|
## Webhooks
|
|
194
165
|
|
|
195
|
-
`Hcp::Event` reads a webhook payload, and reaches the network for nothing
|
|
166
|
+
`Hcp::Event` reads a webhook payload, and reaches the network for nothing. The signature and
|
|
167
|
+
timestamp headers Housecall Pro signs one with are `Hcp::Event::SIGNATURE_HEADER` and
|
|
168
|
+
`Hcp::Event::TIMESTAMP_HEADER`.
|
|
196
169
|
|
|
197
170
|
```ruby
|
|
198
171
|
event = Hcp::Event.new params
|
|
199
|
-
event.
|
|
200
|
-
event.job_id, event.
|
|
172
|
+
event.lead_id, event.customer_id, event.conversion_type, event.conversion_id
|
|
173
|
+
event.job_id, event.estimate_id, event.scheduled_at, event.completed_at
|
|
174
|
+
event.invoice_id, event.invoice_job_id, event.invoice_amount
|
|
201
175
|
```
|
data/lib/hcp/account.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The Housecall Pro account a key opens, acting as one of its locations.
|
|
3
|
+
class Account < Company::Account
|
|
4
|
+
# @param key [String] API key.
|
|
5
|
+
# @param company_id [String, nil] location to act as, where the account has several.
|
|
6
|
+
def initialize(key:, company_id: nil)
|
|
7
|
+
@client = Client.new key: key, company_id: company_id
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @return [Business] business the key belongs to, as the location it acts as.
|
|
11
|
+
def business = Business.new node: @client.get('company')
|
|
12
|
+
|
|
13
|
+
# @return [Jobs] jobs of the location, walked a page at a time.
|
|
14
|
+
def jobs = Jobs.new client: @client
|
|
15
|
+
|
|
16
|
+
# @return [Visits] visits of the location, read off its jobs.
|
|
17
|
+
def visits = Visits.new client: @client
|
|
18
|
+
|
|
19
|
+
# @return [Technicians] employees of the location, walked a page at a time.
|
|
20
|
+
def technicians = Technicians.new client: @client
|
|
21
|
+
|
|
22
|
+
# @return [Leads] leads of the location, to open and to move.
|
|
23
|
+
def leads = Leads.new client: @client
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/hcp/answer.rb
CHANGED
|
@@ -1,35 +1,27 @@
|
|
|
1
1
|
module Hcp
|
|
2
2
|
# What Housecall Pro answered: the body it carries, or the refusal it stands for.
|
|
3
3
|
class Answer
|
|
4
|
-
# The header Housecall Pro names the epoch a rate limit lifts at.
|
|
5
|
-
RESET_HEADER = 'RateLimit-Reset'
|
|
6
|
-
|
|
7
4
|
# @param response [Net::HTTPResponse] what Housecall Pro sent back.
|
|
8
5
|
def initialize(response)
|
|
9
6
|
@response = response
|
|
10
7
|
end
|
|
11
8
|
|
|
12
|
-
# @return [Hash]
|
|
9
|
+
# @return [Hash, nil] record Housecall Pro answered with, or nothing where it sent none.
|
|
13
10
|
def body
|
|
14
11
|
case @response
|
|
15
12
|
when Net::HTTPSuccess then parsed
|
|
16
|
-
when Net::
|
|
17
|
-
when Net::HTTPTooManyRequests then raise TooManyRequests.new(message, reset_at)
|
|
13
|
+
when Net::HTTPTooManyRequests then raise Throttled, message
|
|
18
14
|
else raise Error, message
|
|
19
15
|
end
|
|
20
16
|
end
|
|
21
17
|
|
|
22
18
|
private
|
|
23
19
|
|
|
24
|
-
# Housecall Pro writes a refusal three ways: the message under an `error`, the message as
|
|
25
|
-
# the `error`, and the message on its own.
|
|
26
20
|
def message
|
|
27
21
|
error = parsed['error'] || parsed['message']
|
|
28
22
|
error.is_a?(Hash) ? error['message'] : error
|
|
29
23
|
end
|
|
30
24
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
def parsed = @parsed ||= JSON(@response.body)
|
|
25
|
+
def parsed = @parsed ||= (JSON @response.body if @response.body.present?)
|
|
34
26
|
end
|
|
35
27
|
end
|
data/lib/hcp/client.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Every request to Housecall Pro, made with one key as one location.
|
|
3
|
+
class Client
|
|
4
|
+
# Where Housecall Pro answers.
|
|
5
|
+
HOST = 'https://api.housecallpro.com'
|
|
6
|
+
|
|
7
|
+
# @param key [String] API key every request is made with.
|
|
8
|
+
# @param company_id [String, nil] location to act as, where the account has several.
|
|
9
|
+
def initialize(key:, company_id: nil)
|
|
10
|
+
@key = key
|
|
11
|
+
@company_id = company_id
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] record Housecall Pro answered with.
|
|
15
|
+
def get(path, query = {}) = answer { Net::HTTP.get_response uri(path, query), headers }
|
|
16
|
+
|
|
17
|
+
# @return [Hash] record Housecall Pro opened.
|
|
18
|
+
def post(path, body) = answer { Net::HTTP.post uri(path), body.to_json, headers }
|
|
19
|
+
|
|
20
|
+
# @return [Hash, nil] what Housecall Pro answered, where it answered anything.
|
|
21
|
+
def put(path, body) = answer { Net::HTTP.put uri(path), body.to_json, headers }
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def answer
|
|
26
|
+
Answer.new(yield).body
|
|
27
|
+
rescue Errno::ECONNREFUSED => error
|
|
28
|
+
raise Error, error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def uri(path, query = {}) = URI [ "#{HOST}/#{path}", query.to_query ].compact_blank.join('?')
|
|
32
|
+
|
|
33
|
+
def headers
|
|
34
|
+
{
|
|
35
|
+
'Authorization' => "Token #{@key}",
|
|
36
|
+
'Content-Type' => 'application/json',
|
|
37
|
+
'X-Company-Id' => @company_id,
|
|
38
|
+
}.compact
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The estimates of one location, walked a page at a time. Housecall Pro narrows them by the
|
|
3
|
+
# same words it narrows jobs by, so a window and a crew cost what they cost on a job.
|
|
4
|
+
class Estimates < Company::Collection
|
|
5
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
6
|
+
# @param params [Hash] what the list is narrowed to, as Housecall Pro filters estimates.
|
|
7
|
+
def initialize(client:, params: {})
|
|
8
|
+
@client = client
|
|
9
|
+
@params = params
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Nothing is read until the walk starts, and a page only once the one before it runs out.
|
|
13
|
+
# @yield [Estimate] each estimate in the window, in the order Housecall Pro lists them.
|
|
14
|
+
def each
|
|
15
|
+
(1..).each do |page|
|
|
16
|
+
body = @client.get 'estimates', @params.merge(page: page, page_size: Jobs::PAGE)
|
|
17
|
+
body.fetch('estimates').each { |node| yield Estimate.new node: node }
|
|
18
|
+
break if page >= body.fetch('total_pages')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The jobs of one location, walked a page at a time.
|
|
3
|
+
class Jobs < Company::Collection
|
|
4
|
+
# Jobs a page: the most Housecall Pro answers with, and more than it refuses.
|
|
5
|
+
PAGE = 200
|
|
6
|
+
|
|
7
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
8
|
+
# @param params [Hash] what the list is narrowed to, as Housecall Pro filters jobs.
|
|
9
|
+
def initialize(client:, params: {})
|
|
10
|
+
@client = client
|
|
11
|
+
@params = params
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param from [Time, nil] the moment the window opens, or nothing for as far back as it goes.
|
|
15
|
+
# @param to [Time, nil] the moment the window closes, or nothing for as far ahead as it goes.
|
|
16
|
+
# @return [Jobs] the same list, narrowed to the jobs booked to start between the two.
|
|
17
|
+
def between(from, to)
|
|
18
|
+
bounds = { scheduled_start_min: from&.utc&.iso8601, scheduled_start_max: to&.utc&.iso8601 }
|
|
19
|
+
self.class.new client: @client, params: bounds.compact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Nothing is read until the walk starts, and a page only once the one before it runs out.
|
|
23
|
+
# @yield [Job] each job in the window, in the order Housecall Pro lists them.
|
|
24
|
+
def each
|
|
25
|
+
(1..).each do |page|
|
|
26
|
+
body = @client.get 'jobs', @params.merge(page: page, page_size: PAGE)
|
|
27
|
+
body.fetch('jobs').each { |node| yield Job.new node: node, client: @client }
|
|
28
|
+
break if page >= body.fetch('total_pages')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|