hcp 1.3.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +0 -1
  3. data/CHANGELOG.md +63 -0
  4. data/CLAUDE.md +47 -0
  5. data/README.md +62 -76
  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 -36
  25. metadata +28 -26
  26. data/lib/hcp/concerns/chainable.rb +0 -39
  27. data/lib/hcp/concerns/keyed.rb +0 -14
  28. data/lib/hcp/concerns/named.rb +0 -7
  29. data/lib/hcp/concerns/queryable.rb +0 -41
  30. data/lib/hcp/concerns/scheduled.rb +0 -16
  31. data/lib/hcp/concerns/statused.rb +0 -23
  32. data/lib/hcp/concerns/timestamped.rb +0 -10
  33. data/lib/hcp/errors/not_found.rb +0 -4
  34. data/lib/hcp/errors/too_many_requests.rb +0 -16
  35. data/lib/hcp/filter.rb +0 -36
  36. data/lib/hcp/key.rb +0 -9
  37. data/lib/hcp/lead/pipeline.rb +0 -36
  38. data/lib/hcp/lead.rb +0 -44
  39. data/lib/hcp/relation.rb +0 -76
  40. data/lib/hcp/request.rb +0 -31
  41. data/lib/hcp/resource.rb +0 -58
  42. data/lib/hcp/resources/address.rb +0 -23
  43. data/lib/hcp/resources/employee.rb +0 -12
  44. data/lib/hcp/resources/estimate/option.rb +0 -26
  45. data/lib/hcp/resources/estimate.rb +0 -49
  46. data/lib/hcp/resources/job/appointment.rb +0 -22
  47. data/lib/hcp/resources/job/invoice.rb +0 -21
  48. data/lib/hcp/resources/line_item.rb +0 -20
  49. data/lib/hcp/resources/note.rb +0 -6
  50. data/lib/hcp/resources/schedule.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6efa69ce3e07caf79d11e344137caf623856a6c4ea62bdda98730d852df80e5e
4
- data.tar.gz: 10296089173df4f01acca8945fe39cdff3fdaa28f789730fe402b3322624a338
3
+ metadata.gz: 49b3c65ad4efe2c4a1d58b08001acdeaad85d0a67d3d7c22e53596f1e6e47378
4
+ data.tar.gz: 9e7aaf1f8095223ddcf3d8d3bffe8f04e0478573c4adaf3f6c7bfb843d782359
5
5
  SHA512:
6
- metadata.gz: 82f25858ff585025ee7f1efbd8d099114b18a07f2cac3fc8f2f20d4967b8fe46d68f61d5b922d7ebb80146645fb6d47aa4788df96feae706c8310e99c8992098
7
- data.tar.gz: 0333e25614ba5be270978f6b5022ca27e2e4b1ead840e4804aa73b1287f57b5cbc8ee25bf8aa065061abb5e194c069ec62751e9ce281c8c665d9857bcc2578c4
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,66 @@
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
+
43
+ ## [1.4.0] - 2026-08-28
44
+
45
+ - [Feature] Read as a key for one block, on one thread: `Hcp.with_key(key, company_id:)`,
46
+ which hands the block an `Hcp::Access` answering `account`. A process serving several
47
+ accounts held them all on one `Hcp.key`, so two threads could read each other's; a key
48
+ handed to a block is the thread's alone, and the one set before it comes back after.
49
+
50
+ - [Feature] Read the account a key belongs to: `Hcp::Company.current`, taking `company_id:`.
51
+ There is no list of companies and no ID to find one by, so it is `current` rather than
52
+ `find` or `where`. `locations` is the account itself and then every location under it at
53
+ any depth, flat, so it is never empty; each ID is what `company_id:` takes everywhere else.
54
+
55
+ - [Fix] `Hcp::Address#latitude` and `#longitude` answer a `Float` whichever endpoint they were
56
+ read from. Housecall Pro stamps them as numbers under a customer and as strings under the
57
+ company, and the gem documented `Float` while handing back whatever arrived.
58
+
59
+ - [Feature] Read when the account is free to be booked into: `Hcp::BookingWindow.all`, taking
60
+ `starts_at:`, `days:`, `minutes:`, `service_id:`, `price_form_id:` and `employee_ids:`.
61
+ Housecall Pro answers this list whole rather than a page at a time, so it comes back as an
62
+ `Array` rather than as a relation.
63
+
3
64
  ## [1.3.0] - 2026-08-25
4
65
 
5
66
  - [Feature] Read customers, estimates, jobs and job appointments as an Active Record relation:
@@ -12,6 +73,8 @@
12
73
  - [Feature] Raise `Hcp::NotFound` where Housecall Pro has no such record, and
13
74
  `Hcp::TooManyRequests`, carrying `reset_at`, where it refuses one for rate. Both descend
14
75
  from `Hcp::Error`, so an existing rescue still catches them.
76
+ - [Fix] `require 'hcp'` defines `Hcp::VERSION`, which until now was only set as a side effect
77
+ of Bundler evaluating the gemspec.
15
78
  - [Breaking change] `Hcp::Lead#lead_for`, `#customer_for` and `#uri` are now private, and
16
79
  neither `Hcp::Lead` nor `Hcp::Lead::Pipeline` inherits from `Hcp::Resource`, which is now a
17
80
  record rather than a holder of credentials. Nothing documented ever called them.
data/CLAUDE.md ADDED
@@ -0,0 +1,47 @@
1
+ # hcp
2
+
3
+ A Ruby client for the Housecall Pro API.
4
+
5
+ ## Follow the coding guidelines
6
+
7
+ https://raw.githubusercontent.com/HouseAccountEng/guidelines/refs/heads/main/STYLE.md
8
+
9
+ Read them before writing, not after review. They are stricter than this code alone suggests: one
10
+ line of comment above every public declaration and none above a private one, no metaprogramming
11
+ except on an explicit instruction, no method that nothing calls, no rescue for an error that has
12
+ never happened, and a cap of 100 lines to a file and 50 Ruby files to a folder — both enforced by
13
+ `bundle exec rake`, which is the gate for everything here.
14
+
15
+ Two rules are easy to miss and expensive to undo: a commit message carries no trailer naming its
16
+ author, and a CHANGELOG entry says which of fix, feature or breaking change it is, because that is
17
+ what picks the version.
18
+
19
+ ## Probe the API rather than trust its spec
20
+
21
+ The authoritative OpenAPI spec is at
22
+
23
+ https://stoplight.io/api/v1/projects/housecallpro/housecall-public-api/nodes/reference/housecall.v1.yaml
24
+
25
+ docs.housecallpro.com renders it client-side, so fetching that HTML gets nothing worth reading.
26
+
27
+ **The spec disagrees with the live API.** Send a real request before writing code against a
28
+ documented shape. What has been found so far:
29
+
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.
33
+ - `GET /jobs/{id}/line_items` answers `{"object":"list","data":[…]}`, not the documented
34
+ `{url, data}`.
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
+ A company-scoped key refuses `X-Company-Id` with a 401 on every endpoint, so the header can only
46
+ be exercised with an application key. `GET /company` answers `locations` only to the latter, and
47
+ nests: a location holds locations of its own, several levels deep.
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,119 +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.3'
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
- An account with more than one location passes `company_id:` per call, since one process may
32
- serve several:
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.
32
+
33
+ ## The business
33
34
 
34
35
  ```ruby
35
- Hcp::Job.find id, company_id: company_id
36
- 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
37
40
  ```
38
41
 
39
- ## 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.
44
+
45
+ ## Leads
40
46
 
41
- A list is walked lazily, a page read only once the one before it runs out, so asking for three
42
- 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:
43
48
 
44
49
  ```ruby
45
- Hcp::Job.all
46
- Hcp::Job.find id
47
- Hcp::Job.limit 50
48
- Hcp::Job.order updated_at: :desc
49
- Hcp::Job.where scheduled_at: ..2.days.ago
50
- Hcp::Job.where(customer_id: id).order(created_at: :asc).limit 10
51
- 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
52
55
  ```
53
56
 
54
- A condition written as a range is sent as the two ends Housecall Pro takes, and an end left
55
- 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`:
56
59
 
57
60
  ```ruby
58
- Hcp::Job.where scheduled_at: 2.days.ago.. # scheduled_start_min
59
- Hcp::Job.where scheduled_at: ..2.days.ago # scheduled_start_max
60
- Hcp::Job.where scheduled_at: 1.week.ago..Time.now
61
+ account.leads.find('lea_1').update status_name: 'Won'
61
62
  ```
62
63
 
63
- Housecall Pro narrows a list by one set of words and answers with another, so this gem speaks
64
- the narrower's words on both sides: `where(work_status: :in_progress)` and `job.work_status`
65
- both say `:in_progress`.
64
+ ## Jobs
66
65
 
67
- 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:
68
68
 
69
69
  ```ruby
70
- 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
71
80
  ```
72
81
 
73
- A condition, an order or an expansion Housecall Pro does not take is refused here rather than
74
- 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.
75
84
 
76
- ```ruby
77
- Hcp::Job.where bogus: 1
78
- # => Hcp::Error: bogus is not one of: scheduled_at, ends_at, customer_id, ...
79
- ```
85
+ ## Visits
80
86
 
81
- ### What each resource reads
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.
82
91
 
83
92
  ```ruby
84
- job = Hcp::Job.find id
85
- job.description, job.work_status, job.total_amount, job.invoice_number
86
- job.customer.name, job.address.city, job.assigned_employees, job.notes, job.tags
87
- job.schedule.starts_at, job.schedule.time_zone, job.completed_at
88
- job.appointments, job.line_items, job.invoices
89
-
90
- customer = Hcp::Customer.where(q: 'Ada').first
91
- customer.name, customer.email, customer.phone, customer.kind, customer.addresses
92
-
93
- estimate = Hcp::Estimate.find id
94
- estimate.estimate_number, estimate.options.map(&:total_amount)
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
95
97
  ```
96
98
 
97
- Housecall Pro counts money in cents; this gem reads it in dollars, as a `BigDecimal`.
98
-
99
99
  ## Errors
100
100
 
101
- Everything descends from `Hcp::Error`, so one rescue still catches the lot.
102
-
103
- ```ruby
104
- Hcp::NotFound # Housecall Pro has no record under that ID
105
- Hcp::TooManyRequests # refused for rate; #reset_at says when it lifts
106
- ```
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.
107
104
 
108
105
  Nothing here sleeps. A caller told to come back later has a queue that can bring the whole job
109
106
  back, which is worth more than a worker asleep holding a connection open.
110
107
 
111
- ## Leads
112
-
113
- Opening a lead, and moving one through the pipeline, are unchanged:
114
-
115
- ```ruby
116
- lead = Hcp::Lead.new key:, company_id:
117
- lead.create name: 'Ada', phone: '5550000001', email: 'ada@example.com',
118
- address: { street: '1 Example Street', city: 'Springfield', state: 'CA', zip: '90210' },
119
- note: 'Very interested in buying', source: 'The Lead Generator'
120
-
121
- pipeline = Hcp::Lead::Pipeline.new id:, key:, company_id:
122
- pipeline.update status_name: 'Won'
123
- ```
124
-
125
108
  ## Webhooks
126
109
 
127
- `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`.
128
113
 
129
114
  ```ruby
130
115
  event = Hcp::Event.new params
131
- event.type # :job_scheduled
132
- 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
133
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