hcp 1.4.0 → 2.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 +40 -0
- data/CLAUDE.md +13 -9
- data/README.md +60 -142
- data/lib/hcp/account.rb +22 -0
- data/lib/hcp/answer.rb +3 -11
- data/lib/hcp/client.rb +41 -0
- data/lib/hcp/collections/jobs.rb +32 -0
- data/lib/hcp/collections/leads.rb +25 -0
- data/lib/hcp/collections/visits.rb +39 -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/job.rb +54 -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/visit.rb +21 -0
- data/lib/hcp/version.rb +3 -3
- data/lib/hcp.rb +19 -42
- metadata +27 -29
- 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/estimate.rb +0 -49
- 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: 49b3c65ad4efe2c4a1d58b08001acdeaad85d0a67d3d7c22e53596f1e6e47378
|
|
4
|
+
data.tar.gz: 9e7aaf1f8095223ddcf3d8d3bffe8f04e0478573c4adaf3f6c7bfb843d782359
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be5b0a3350884a96232554a0b3f44539a5aca0bec65ee4d42fdc6ca332a75d69159ca03ada11a422eacd8711d352b0e6f4119ec48b16a7388f7d1fc9a9e6913d
|
|
7
|
+
data.tar.gz: b0f80e696be5103618a7a63e056b2fc14747374f925da2003799be4bac9d4a84c274f21ef41457c0e6865ee968d955bee9043d5d5a13dd3cffe5bd1d11be2dd9
|
data/.yardopts
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [2.0.0] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
- [Breaking change] The key is one account's: `Hcp::Account.new(key:, company_id:)`, a
|
|
6
|
+
`Company::Account`, answers `business` and `leads`. `Hcp.key`, `Hcp.with_key`, `Hcp::Access`
|
|
7
|
+
and `HCP_KEY` go.
|
|
8
|
+
|
|
9
|
+
- [Breaking change] `Hcp::Company` is `Hcp::Business`, a `Company::Business`: `id`, `name`,
|
|
10
|
+
`phone` as ten digits and `subsidiaries`, which was `locations` and which the vocabulary
|
|
11
|
+
now lists off the locations Housecall Pro nests. `website`, `time_zone`,
|
|
12
|
+
`logo_url`, `support_email`, `arrival_window`, `address` and `zip_codes` go.
|
|
13
|
+
|
|
14
|
+
- [Breaking change] `Hcp::Lead.new(key:, company_id:).create` is `account.leads.create`, which
|
|
15
|
+
takes the vocabulary's words -- `name:`, `surname:`, `phone:`, `email:`, `address:`,
|
|
16
|
+
`description:`, `notes:`, `source:`, the description heading the note -- and answers the lead; `Hcp::Lead::Pipeline` is
|
|
17
|
+
`account.leads.find(id).update(status_name:)`. Writes go through the same plumbing as reads,
|
|
18
|
+
so a refusal is read the three ways Housecall Pro writes one.
|
|
19
|
+
|
|
20
|
+
- [Breaking change] Gone, unread by any caller: `Relation`, `Chainable`, `Queryable`, `Filter`,
|
|
21
|
+
`Job`, `Job::Appointment`, `Job::Invoice`, `Estimate`, `Estimate::Option`, `Customer`,
|
|
22
|
+
`Employee`, `Schedule`, `Note`, `LineItem`, `Address`, `BookingWindow`, `Hcp::NotFound` --
|
|
23
|
+
a 404 raises `Hcp::Error` -- `Hcp::TooManyRequests#reset_at` and `Hcp::Event#type`.
|
|
24
|
+
|
|
25
|
+
- [Breaking change] `Hcp::TooManyRequests` is `Hcp::Throttled`, a `Company::Throttled` rather
|
|
26
|
+
than an `Hcp::Error`, so one rescue retries a refusal for rate from any platform.
|
|
27
|
+
|
|
28
|
+
- [Feature] `account.jobs.past(within)` walks the jobs booked to start in the window a page at a
|
|
29
|
+
time, each an `Hcp::Job` reading in the vocabulary: `scheduled_at` and `completed_at` off the
|
|
30
|
+
nested schedule and timestamps, `notes` listed one to a line,
|
|
31
|
+
`amount` in dollars off the cents, `quote` the estimate option
|
|
32
|
+
the job was created from -- its `amount` the option's total, found once among the customer's
|
|
33
|
+
estimates --
|
|
34
|
+
`location` with its `customer` beside it, and `lines` read once off the job's own endpoint.
|
|
35
|
+
|
|
36
|
+
- [Feature] `account.visits.upcoming(within)` walks the visits booked to start in the window,
|
|
37
|
+
which Housecall Pro calls appointments and files inside jobs: the jobs booked across the
|
|
38
|
+
window are read with their appointments, a canceled job's are skipped, and each `Hcp::Visit`
|
|
39
|
+
answers `starts_at`, `ends_at`, `anytime?`, its job's `description` and the `job` itself.
|
|
40
|
+
|
|
41
|
+
- [Feature] `Hcp::Error` descends from `Company::Error`, so one rescue covers every platform.
|
|
42
|
+
|
|
3
43
|
## [1.4.0] - 2026-08-28
|
|
4
44
|
|
|
5
45
|
- [Feature] Read as a key for one block, on one thread: `Hcp.with_key(key, company_id:)`,
|
data/CLAUDE.md
CHANGED
|
@@ -25,18 +25,22 @@ 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.
|
|
40
44
|
|
|
41
45
|
A company-scoped key refuses `X-Company-Id` with a 401 on every endpoint, so the header can only
|
|
42
46
|
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,107 @@ 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', '~> 2.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
|
-
|
|
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.
|
|
33
32
|
|
|
34
|
-
|
|
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
|
-
```
|
|
40
|
-
|
|
41
|
-
Neither thread sees the other's key, and the one set before the block comes back after it.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
## Reading
|
|
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.
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
records costs one request rather than all of them:
|
|
57
|
-
|
|
58
|
-
```ruby
|
|
59
|
-
Hcp::Job.all
|
|
60
|
-
Hcp::Job.find id
|
|
61
|
-
Hcp::Job.limit 50
|
|
62
|
-
Hcp::Job.order updated_at: :desc
|
|
63
|
-
Hcp::Job.where scheduled_at: ..2.days.ago
|
|
64
|
-
Hcp::Job.where(customer_id: id).order(created_at: :asc).limit 10
|
|
65
|
-
Hcp::Job.all.count
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
A condition written as a range is sent as the two ends Housecall Pro takes, and an end left
|
|
69
|
-
open is left out:
|
|
70
|
-
|
|
71
|
-
```ruby
|
|
72
|
-
Hcp::Job.where scheduled_at: 2.days.ago.. # scheduled_start_min
|
|
73
|
-
Hcp::Job.where scheduled_at: ..2.days.ago # scheduled_start_max
|
|
74
|
-
Hcp::Job.where scheduled_at: 1.week.ago..Time.now
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Housecall Pro narrows a list by one set of words and answers with another, so this gem speaks
|
|
78
|
-
the narrower's words on both sides: `where(work_status: :in_progress)` and `job.work_status`
|
|
79
|
-
both say `:in_progress`.
|
|
80
|
-
|
|
81
|
-
Nothing comes back unasked. `includes` asks for what Housecall Pro otherwise leaves out:
|
|
82
|
-
|
|
83
|
-
```ruby
|
|
84
|
-
Hcp::Job.includes(:appointments).limit 20
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
A condition, an order or an expansion Housecall Pro does not take is refused here rather than
|
|
88
|
-
sent — it answers an unknown condition by ignoring it and handing back the whole account:
|
|
89
|
-
|
|
90
|
-
```ruby
|
|
91
|
-
Hcp::Job.where bogus: 1
|
|
92
|
-
# => Hcp::Error: bogus is not one of: scheduled_at, ends_at, customer_id, ...
|
|
93
|
-
```
|
|
45
|
+
## Leads
|
|
94
46
|
|
|
95
|
-
|
|
47
|
+
Opening a lead opens its customer with it, and hands back what Housecall Pro filed both under:
|
|
96
48
|
|
|
97
49
|
```ruby
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
customer = Hcp::Customer.where(q: 'Ada').first
|
|
105
|
-
customer.name, customer.email, customer.phone, customer.kind, customer.addresses
|
|
106
|
-
|
|
107
|
-
estimate = Hcp::Estimate.find id
|
|
108
|
-
estimate.estimate_number, estimate.options.map(&:total_amount)
|
|
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
|
|
109
55
|
```
|
|
110
56
|
|
|
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`:
|
|
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`:
|
|
118
59
|
|
|
119
60
|
```ruby
|
|
120
|
-
|
|
121
|
-
company.name, company.phone, company.support_email, company.website, company.logo_url
|
|
122
|
-
company.time_zone, company.arrival_window, company.address.city, company.zip_codes
|
|
61
|
+
account.leads.find('lea_1').update status_name: 'Won'
|
|
123
62
|
```
|
|
124
63
|
|
|
125
|
-
|
|
126
|
-
are the ones the account will travel to.
|
|
64
|
+
## Jobs
|
|
127
65
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
franchise reads flat however deep Housecall Pro nests it:
|
|
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:
|
|
131
68
|
|
|
132
69
|
```ruby
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Hcp::
|
|
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
|
|
141
80
|
```
|
|
142
81
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
When the account is free to be booked into, as its Online Booking settings answer it. Housecall
|
|
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.
|
|
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.
|
|
148
84
|
|
|
149
|
-
|
|
150
|
-
Hcp::BookingWindow.all
|
|
151
|
-
Hcp::BookingWindow.all starts_at: Date.tomorrow, days: 14
|
|
152
|
-
Hcp::BookingWindow.all employee_ids: [ employee.id ]
|
|
153
|
-
Hcp::BookingWindow.all service_id: id, minutes: 90
|
|
154
|
-
```
|
|
85
|
+
## Visits
|
|
155
86
|
|
|
156
|
-
|
|
87
|
+
Housecall Pro calls a visit an appointment and files it inside a job, so the visits booked to
|
|
88
|
+
start within a window are read off the jobs booked across it, a page of jobs at a time, each
|
|
89
|
+
saying what its job says and carrying it. A job called off keeps its visits to itself, and a
|
|
90
|
+
list nothing narrows walks every job there was.
|
|
157
91
|
|
|
158
92
|
```ruby
|
|
159
|
-
|
|
160
|
-
|
|
93
|
+
account.visits.upcoming(2.weeks).each do |visit|
|
|
94
|
+
visit.id, visit.description, visit.starts_at, visit.ends_at, visit.anytime?
|
|
95
|
+
visit.job # => the Hcp::Job the stop belongs to, its location and customer along
|
|
96
|
+
end
|
|
161
97
|
```
|
|
162
98
|
|
|
163
|
-
Left out, `starts_at` is the next day holding a free window and `days` is seven. A window is
|
|
164
|
-
cut to the service's own duration where `service_id` names one, to `minutes` where that is
|
|
165
|
-
given, and to thirty minutes otherwise.
|
|
166
|
-
|
|
167
99
|
## Errors
|
|
168
100
|
|
|
169
|
-
Everything descends from `Hcp::Error`,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
Hcp::NotFound # Housecall Pro has no record under that ID
|
|
173
|
-
Hcp::TooManyRequests # refused for rate; #reset_at says when it lifts
|
|
174
|
-
```
|
|
101
|
+
Everything descends from `Hcp::Error`, which descends from `Company::Error`, so one rescue
|
|
102
|
+
still catches the lot. `Hcp::Throttled`, a `Company::Throttled`, is a refusal for rate, so one
|
|
103
|
+
retry covers every platform.
|
|
175
104
|
|
|
176
105
|
Nothing here sleeps. A caller told to come back later has a queue that can bring the whole job
|
|
177
106
|
back, which is worth more than a worker asleep holding a connection open.
|
|
178
107
|
|
|
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
108
|
## Webhooks
|
|
194
109
|
|
|
195
|
-
`Hcp::Event` reads a webhook payload, and reaches the network for nothing
|
|
110
|
+
`Hcp::Event` reads a webhook payload, and reaches the network for nothing. The signature and
|
|
111
|
+
timestamp headers Housecall Pro signs one with are `Hcp::Event::SIGNATURE_HEADER` and
|
|
112
|
+
`Hcp::Event::TIMESTAMP_HEADER`.
|
|
196
113
|
|
|
197
114
|
```ruby
|
|
198
115
|
event = Hcp::Event.new params
|
|
199
|
-
event.
|
|
200
|
-
event.job_id, event.
|
|
116
|
+
event.lead_id, event.customer_id, event.conversion_type, event.conversion_id
|
|
117
|
+
event.job_id, event.estimate_id, event.scheduled_at, event.completed_at
|
|
118
|
+
event.invoice_id, event.invoice_job_id, event.invoice_amount
|
|
201
119
|
```
|
data/lib/hcp/account.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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 [Leads] leads of the location, to open and to move.
|
|
20
|
+
def leads = Leads.new client: @client
|
|
21
|
+
end
|
|
22
|
+
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,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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The leads of one location: who asked a Housecall Pro user for work, before there is a job.
|
|
3
|
+
class Leads < Company::Leads
|
|
4
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
5
|
+
def initialize(client:)
|
|
6
|
+
@client = client
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Opens a lead, and a customer with it, on Housecall Pro. A lead has no title of its own, so
|
|
10
|
+
# the description heads the note and the notes follow it.
|
|
11
|
+
# @return [Lead] lead as Housecall Pro filed it, with its customer's ID beside it.
|
|
12
|
+
def create(name:, surname:, phone:, email:, address:, description:, notes:, source:)
|
|
13
|
+
customer = { first_name: name, last_name: surname, email: email, mobile_number: phone,
|
|
14
|
+
lead_source: source, }
|
|
15
|
+
body = { customer: customer.compact_blank, address: address, lead_source: source,
|
|
16
|
+
note: [ description, notes ].compact_blank.join("\n"), }
|
|
17
|
+
Lead.new node: @client.post('leads', body.compact_blank), client: @client
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reaches the network for nothing: the lead is named, and moved once it is asked to be.
|
|
21
|
+
# @param id [String] ID Housecall Pro files the lead under.
|
|
22
|
+
# @return [Lead] the lead, able to move through the pipeline.
|
|
23
|
+
def find(id) = Lead.new node: { id: id }, client: @client
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The visits of one location, which Housecall Pro calls appointments and files inside jobs:
|
|
3
|
+
# they are read off the jobs booked across a window, a page of jobs at a time.
|
|
4
|
+
class Visits < Company::Collection
|
|
5
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
6
|
+
# @param from [Time, nil] the moment the window opens, or nothing for every visit there was.
|
|
7
|
+
# @param to [Time, nil] the moment the window closes, or nothing for every visit to come.
|
|
8
|
+
def initialize(client:, from: nil, to: nil)
|
|
9
|
+
@client = client
|
|
10
|
+
@from = from
|
|
11
|
+
@to = to
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param from [Time, nil] the moment the window opens, or nothing for every visit there was.
|
|
15
|
+
# @param to [Time, nil] the moment the window closes, or nothing for every visit to come.
|
|
16
|
+
# @return [Visits] the same list, narrowed to the visits booked to start between the two.
|
|
17
|
+
def between(from, to) = self.class.new(client: @client, from: from, to: to)
|
|
18
|
+
|
|
19
|
+
# A job booked across the window carries every visit in it, so the jobs are read once and
|
|
20
|
+
# a canceled job's visits are left where they are.
|
|
21
|
+
# @yield [Visit] each visit in the window, in the order Housecall Pro lists them.
|
|
22
|
+
def each
|
|
23
|
+
jobs.each do |job|
|
|
24
|
+
next if job.canceled?
|
|
25
|
+
|
|
26
|
+
job.visits.each { |visit| yield visit if window.cover? visit.starts_at }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def window = @from..@to
|
|
33
|
+
|
|
34
|
+
def jobs
|
|
35
|
+
bounds = { scheduled_end_min: @from&.utc&.iso8601, scheduled_start_max: @to&.utc&.iso8601 }
|
|
36
|
+
Jobs.new client: @client, params: bounds.compact.merge(expand: [ 'appointments' ])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/hcp/error.rb
CHANGED
data/lib/hcp/event.rb
CHANGED
|
@@ -8,13 +8,10 @@ module Hcp
|
|
|
8
8
|
TIMESTAMP_HEADER = 'Api-Timestamp'
|
|
9
9
|
|
|
10
10
|
# @param params [Hash] the payload for an event webhook.
|
|
11
|
-
def initialize(params
|
|
11
|
+
def initialize(params)
|
|
12
12
|
@params = params
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# @return [Symbol] the type of event, e.g.: :lead_converted, :job_created, :invoice_sent.
|
|
16
|
-
def type = @params.fetch(:event, '').gsub('.', '_').to_sym
|
|
17
|
-
|
|
18
15
|
# @return [String] unique identifier of the lead in a :lead_converted event.
|
|
19
16
|
def lead_id = @params.dig :lead, :id
|
|
20
17
|
|
|
@@ -50,10 +47,8 @@ module Hcp
|
|
|
50
47
|
|
|
51
48
|
private
|
|
52
49
|
|
|
53
|
-
# @return [Hash] the latest conversion, applies to 'lead.converted' events.
|
|
54
50
|
def conversion = @params.dig(:lead, :conversions).last
|
|
55
51
|
|
|
56
|
-
# @return [Symbol] the type of resource affected by the event, can be :lead or :job.
|
|
57
52
|
def resource_type = @params[:event].split('.').first.to_sym
|
|
58
53
|
end
|
|
59
54
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The business behind a key: the account, or one location of a franchise.
|
|
3
|
+
class Business < Company::Business
|
|
4
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { phone: :phone_number }
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def below = records Business, :locations
|
|
10
|
+
end
|
|
11
|
+
end
|