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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +0 -1
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +13 -9
  5. data/README.md +60 -142
  6. data/lib/hcp/account.rb +22 -0
  7. data/lib/hcp/answer.rb +3 -11
  8. data/lib/hcp/client.rb +41 -0
  9. data/lib/hcp/collections/jobs.rb +32 -0
  10. data/lib/hcp/collections/leads.rb +25 -0
  11. data/lib/hcp/collections/visits.rb +39 -0
  12. data/lib/hcp/error.rb +1 -1
  13. data/lib/hcp/errors/throttled.rb +4 -0
  14. data/lib/hcp/event.rb +1 -6
  15. data/lib/hcp/resources/business.rb +11 -0
  16. data/lib/hcp/resources/customer.rb +9 -45
  17. data/lib/hcp/resources/job.rb +54 -69
  18. data/lib/hcp/resources/lead.rb +26 -0
  19. data/lib/hcp/resources/line.rb +8 -0
  20. data/lib/hcp/resources/location.rb +7 -0
  21. data/lib/hcp/resources/quote.rb +28 -0
  22. data/lib/hcp/resources/visit.rb +21 -0
  23. data/lib/hcp/version.rb +3 -3
  24. data/lib/hcp.rb +19 -42
  25. metadata +27 -29
  26. data/lib/hcp/access.rb +0 -12
  27. data/lib/hcp/concerns/chainable.rb +0 -39
  28. data/lib/hcp/concerns/keyed.rb +0 -14
  29. data/lib/hcp/concerns/named.rb +0 -7
  30. data/lib/hcp/concerns/queryable.rb +0 -41
  31. data/lib/hcp/concerns/scheduled.rb +0 -16
  32. data/lib/hcp/concerns/statused.rb +0 -23
  33. data/lib/hcp/concerns/timestamped.rb +0 -10
  34. data/lib/hcp/errors/not_found.rb +0 -4
  35. data/lib/hcp/errors/too_many_requests.rb +0 -16
  36. data/lib/hcp/filter.rb +0 -36
  37. data/lib/hcp/key.rb +0 -24
  38. data/lib/hcp/lead/pipeline.rb +0 -36
  39. data/lib/hcp/lead.rb +0 -44
  40. data/lib/hcp/relation.rb +0 -76
  41. data/lib/hcp/request.rb +0 -31
  42. data/lib/hcp/resource.rb +0 -58
  43. data/lib/hcp/resources/address.rb +0 -24
  44. data/lib/hcp/resources/booking_window.rb +0 -52
  45. data/lib/hcp/resources/company.rb +0 -42
  46. data/lib/hcp/resources/employee.rb +0 -12
  47. data/lib/hcp/resources/estimate/option.rb +0 -26
  48. data/lib/hcp/resources/estimate.rb +0 -49
  49. data/lib/hcp/resources/job/appointment.rb +0 -22
  50. data/lib/hcp/resources/job/invoice.rb +0 -21
  51. data/lib/hcp/resources/line_item.rb +0 -20
  52. data/lib/hcp/resources/note.rb +0 -6
  53. data/lib/hcp/resources/schedule.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7eda96bf4ffa8ef77fdb696d95c2f47bed39b593ef075051ae40c5fe5f2e64c8
4
- data.tar.gz: 52b938c9001e14231015447cc203d7ea72899a4ef982d58aa2f41ce88cf99c66
3
+ metadata.gz: 49b3c65ad4efe2c4a1d58b08001acdeaad85d0a67d3d7c22e53596f1e6e47378
4
+ data.tar.gz: 9e7aaf1f8095223ddcf3d8d3bffe8f04e0478573c4adaf3f6c7bfb843d782359
5
5
  SHA512:
6
- metadata.gz: 68b998e5fcaaf1656ea9fef6d11a8dff91c4a7a46a4f5b87c3d681dac2f99b4a10b4eae8cb2b115c2f272ce100667cfed87c83700fdd778380c2370ea877f8e9
7
- data.tar.gz: 694e677888b6b317779b07f11f9f8644ac1b8e68fd8643b2a3bd300057cb43c1a696c05d844ed25f4310a0eba465824c33ea459ba73b341f3eeb1378ee3c349c
6
+ metadata.gz: be5b0a3350884a96232554a0b3f44539a5aca0bec65ee4d42fdc6ca332a75d69159ca03ada11a422eacd8711d352b0e6f4119ec48b16a7388f7d1fc9a9e6913d
7
+ data.tar.gz: b0f80e696be5103618a7a63e056b2fc14747374f925da2003799be4bac9d4a84c274f21ef41457c0e6865ee968d955bee9043d5d5a13dd3cffe5bd1d11be2dd9
data/.yardopts CHANGED
@@ -1,5 +1,4 @@
1
1
  --readme README.md
2
2
  --markup markdown
3
3
  --no-private
4
- lib/hcp/resource.rb
5
4
  lib/**/*.rb
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. Six disagreements found so far, each of which would have shipped a bug:
28
+ documented shape. What has been found so far:
29
29
 
30
- - `page_size` is capped at 200. The spec publishes no maximum.
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
- - A refusal comes back three ways `{"error":{"message":…}}`, `{"error":"…"}` and `{"message":…}`.
34
- The spec describes only the first.
35
- - An unknown filter is **ignored, not refused**, so a typo answers the whole account rather than a
36
- page of it. This is why the gem checks condition names itself.
37
- - `GET /company` answers `default_arrival_window` as an integer. The spec publishes it as a string.
38
- - `GET /company` stamps an address's `latitude` and `longitude` as **strings**, where
39
- `GET /customers/{id}/addresses` answers the same fields as numbers.
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
- Every method in this gem is one Housecall Pro endpoint. Where Housecall Pro has no endpoint,
4
- this gem has no method so a resource answers `update` only where Housecall Pro takes a `PUT`,
5
- and a job, which it takes none for, answers none.
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', '~> 1.4'
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 key, and the location
21
+ ## The account
24
22
 
25
- Set the key once. Left unset, the gem reads `HCP_KEY` from the environment:
23
+ An account is a key, acting as one location where the key opens several:
26
24
 
27
25
  ```ruby
28
- Hcp.key = ENV['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
- A process serving several accounts hands each thread its own key for as long as a block runs,
32
- and is handed back the account that key opens:
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
- ```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
- ```
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
- Hcp::Job.find id, company_id: company_id
48
- Hcp::Job.all(company_id: company_id).where(work_status: :scheduled)
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
- `Hcp::Company.current.locations` is where those IDs come from.
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
- A list is walked lazily, a page read only once the one before it runs out, so asking for three
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
- ### What each resource reads
47
+ Opening a lead opens its customer with it, and hands back what Housecall Pro filed both under:
96
48
 
97
49
  ```ruby
98
- job = Hcp::Job.find id
99
- job.description, job.work_status, job.total_amount, job.invoice_number
100
- job.customer.name, job.address.city, job.assigned_employees, job.notes, job.tags
101
- job.schedule.starts_at, job.schedule.time_zone, job.completed_at
102
- job.appointments, job.line_items, job.invoices
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
- Housecall Pro counts money in cents; this gem reads it in dollars, as a `BigDecimal`.
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
- company = Hcp::Company.current
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
- `arrival_window` is how many minutes wide a customer's window is by default, and `zip_codes`
126
- are the ones the account will travel to.
64
+ ## Jobs
127
65
 
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:
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
- Hcp::Company.current.locations.map(&:name)
134
- # => ['Acme Handyman HQ', 'Acme Handyman East', 'Acme Handyman of Springfield', …]
135
- ```
136
-
137
- Each location's `id` is what `company_id:` takes, here and everywhere else:
138
-
139
- ```ruby
140
- Hcp::Company.current company_id: location.id
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
- ## Booking windows
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
- ```ruby
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
- Each window says when it opens, when it closes, and whether it is free:
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
- free = Hcp::BookingWindow.all(days: 3).select(&:available?)
160
- free.map { |window| [ window.starts_at, window.ends_at ] }
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`, so one rescue still catches the lot.
170
-
171
- ```ruby
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.type # :job_scheduled
200
- event.job_id, event.customer_id, event.scheduled_at, event.invoice_amount
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
  ```
@@ -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] the record or page Housecall Pro answered with.
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::HTTPNotFound then raise NotFound, message
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 reset_at = (Time.at Integer(@response[RESET_HEADER]) if @response[RESET_HEADER])
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
@@ -1,4 +1,4 @@
1
1
  module Hcp
2
2
  # Raised where Housecall Pro would not answer.
3
- Error = Class.new StandardError
3
+ Error = Class.new Company::Error
4
4
  end
@@ -0,0 +1,4 @@
1
+ module Hcp
2
+ # Raised where Housecall Pro refuses a request for rate, and answers it again a little later.
3
+ Throttled = Class.new Company::Throttled
4
+ end
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