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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +0 -1
  3. data/CHANGELOG.md +75 -0
  4. data/CLAUDE.md +34 -9
  5. data/README.md +99 -125
  6. data/lib/hcp/account.rb +25 -0
  7. data/lib/hcp/answer.rb +3 -11
  8. data/lib/hcp/client.rb +41 -0
  9. data/lib/hcp/collections/estimates.rb +22 -0
  10. data/lib/hcp/collections/jobs.rb +32 -0
  11. data/lib/hcp/collections/leads.rb +25 -0
  12. data/lib/hcp/collections/technicians.rb +23 -0
  13. data/lib/hcp/collections/visits.rb +80 -0
  14. data/lib/hcp/error.rb +1 -1
  15. data/lib/hcp/errors/throttled.rb +4 -0
  16. data/lib/hcp/event.rb +1 -6
  17. data/lib/hcp/resources/business.rb +11 -0
  18. data/lib/hcp/resources/customer.rb +9 -45
  19. data/lib/hcp/resources/estimate.rb +40 -46
  20. data/lib/hcp/resources/job.rb +58 -69
  21. data/lib/hcp/resources/lead.rb +26 -0
  22. data/lib/hcp/resources/line.rb +8 -0
  23. data/lib/hcp/resources/location.rb +7 -0
  24. data/lib/hcp/resources/quote.rb +28 -0
  25. data/lib/hcp/resources/technician.rb +7 -0
  26. data/lib/hcp/resources/visit.rb +45 -0
  27. data/lib/hcp/version.rb +3 -3
  28. data/lib/hcp.rb +22 -41
  29. metadata +30 -28
  30. data/lib/hcp/access.rb +0 -12
  31. data/lib/hcp/concerns/chainable.rb +0 -39
  32. data/lib/hcp/concerns/keyed.rb +0 -14
  33. data/lib/hcp/concerns/named.rb +0 -7
  34. data/lib/hcp/concerns/queryable.rb +0 -41
  35. data/lib/hcp/concerns/scheduled.rb +0 -16
  36. data/lib/hcp/concerns/statused.rb +0 -23
  37. data/lib/hcp/concerns/timestamped.rb +0 -10
  38. data/lib/hcp/errors/not_found.rb +0 -4
  39. data/lib/hcp/errors/too_many_requests.rb +0 -16
  40. data/lib/hcp/filter.rb +0 -36
  41. data/lib/hcp/key.rb +0 -24
  42. data/lib/hcp/lead/pipeline.rb +0 -36
  43. data/lib/hcp/lead.rb +0 -44
  44. data/lib/hcp/relation.rb +0 -76
  45. data/lib/hcp/request.rb +0 -31
  46. data/lib/hcp/resource.rb +0 -58
  47. data/lib/hcp/resources/address.rb +0 -24
  48. data/lib/hcp/resources/booking_window.rb +0 -52
  49. data/lib/hcp/resources/company.rb +0 -42
  50. data/lib/hcp/resources/employee.rb +0 -12
  51. data/lib/hcp/resources/estimate/option.rb +0 -26
  52. data/lib/hcp/resources/job/appointment.rb +0 -22
  53. data/lib/hcp/resources/job/invoice.rb +0 -21
  54. data/lib/hcp/resources/line_item.rb +0 -20
  55. data/lib/hcp/resources/note.rb +0 -6
  56. 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: 796d536211d1039f154af0ea0340de4e814d9cf86c9009e60af68670dbc16cda
4
+ data.tar.gz: cd18b17cc8e162a750c998f5d554835d6982d75a7b417c4d60dc26dcfda9106a
5
5
  SHA512:
6
- metadata.gz: 68b998e5fcaaf1656ea9fef6d11a8dff91c4a7a46a4f5b87c3d681dac2f99b4a10b4eae8cb2b115c2f272ce100667cfed87c83700fdd778380c2370ea877f8e9
7
- data.tar.gz: 694e677888b6b317779b07f11f9f8644ac1b8e68fd8643b2a3bd300057cb43c1a696c05d844ed25f4310a0eba465824c33ea459ba73b341f3eeb1378ee3c349c
6
+ metadata.gz: 1a6302bcfa3722c31caae3cc45d2bfb7d30c60895869c6a03fcc8d7851db99be8ad7f1b460d704f51a268e11fe2a78fef2e351186f9617f312d0ae759d0043e2
7
+ data.tar.gz: 65d2d60980d7386121a6f411f591398741fda04298a42d39102666b530ebd5239c4cfbe4297ea98d9d99721c2719505674440360551269352a52e0f2d69fceb3
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,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. 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.
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
- 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,163 @@ 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', '~> 3.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:
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
- 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.
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
- ## Reading
45
+ ## Leads
54
46
 
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:
47
+ Opening a lead opens its customer with it, and hands back what Housecall Pro filed both under:
57
48
 
58
49
  ```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
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
- A condition written as a range is sent as the two ends Housecall Pro takes, and an end left
69
- open is left out:
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
- 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
61
+ account.leads.find('lea_1').update status_name: 'Won'
75
62
  ```
76
63
 
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`.
64
+ ## Jobs
80
65
 
81
- Nothing comes back unasked. `includes` asks for what Housecall Pro otherwise leaves out:
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
- Hcp::Job.includes(:appointments).limit 20
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 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:
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
- ```ruby
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
- ### What each resource reads
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
- 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)
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
- 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`:
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
- 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
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
- `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.
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
- Each location's `id` is what `company_id:` takes, here and everywhere else:
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
- ```ruby
140
- Hcp::Company.current company_id: location.id
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
- ## Booking windows
125
+ ## The schedule
144
126
 
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.
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
- 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
130
+ account.technicians.each do |technician|
131
+ technician.id, technician.name, technician.surname
132
+ end
154
133
  ```
155
134
 
156
- Each window says when it opens, when it closes, and whether it is free:
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
- free = Hcp::BookingWindow.all(days: 3).select(&:available?)
160
- free.map { |window| [ window.starts_at, window.ends_at ] }
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
- 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.
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
- ## Errors
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
- Everything descends from `Hcp::Error`, so one rescue still catches the lot.
155
+ ## Errors
170
156
 
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
- ```
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.type # :job_scheduled
200
- event.job_id, event.customer_id, event.scheduled_at, event.invoice_amount
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
  ```
@@ -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] 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,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