hcp 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +5 -0
- data/CHANGELOG.md +18 -0
- data/README.md +121 -8
- data/lib/hcp/answer.rb +35 -0
- data/lib/hcp/concerns/chainable.rb +39 -0
- data/lib/hcp/concerns/keyed.rb +14 -0
- data/lib/hcp/concerns/named.rb +7 -0
- data/lib/hcp/concerns/queryable.rb +41 -0
- data/lib/hcp/concerns/scheduled.rb +16 -0
- data/lib/hcp/concerns/statused.rb +23 -0
- data/lib/hcp/concerns/timestamped.rb +10 -0
- data/lib/hcp/error.rb +1 -0
- data/lib/hcp/errors/not_found.rb +4 -0
- data/lib/hcp/errors/too_many_requests.rb +16 -0
- data/lib/hcp/event.rb +1 -0
- data/lib/hcp/filter.rb +36 -0
- data/lib/hcp/key.rb +9 -0
- data/lib/hcp/lead/pipeline.rb +6 -3
- data/lib/hcp/lead.rb +10 -3
- data/lib/hcp/relation.rb +76 -0
- data/lib/hcp/request.rb +31 -0
- data/lib/hcp/resource.rb +50 -7
- data/lib/hcp/resources/address.rb +23 -0
- data/lib/hcp/resources/customer.rb +52 -0
- data/lib/hcp/resources/employee.rb +12 -0
- data/lib/hcp/resources/estimate/option.rb +26 -0
- data/lib/hcp/resources/estimate.rb +49 -0
- data/lib/hcp/resources/job/appointment.rb +22 -0
- data/lib/hcp/resources/job/invoice.rb +21 -0
- data/lib/hcp/resources/job.rb +73 -0
- data/lib/hcp/resources/line_item.rb +20 -0
- data/lib/hcp/resources/note.rb +6 -0
- data/lib/hcp/resources/schedule.rb +21 -0
- data/lib/hcp/version.rb +1 -3
- data/lib/hcp.rb +42 -2
- metadata +113 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6efa69ce3e07caf79d11e344137caf623856a6c4ea62bdda98730d852df80e5e
|
|
4
|
+
data.tar.gz: 10296089173df4f01acca8945fe39cdff3fdaa28f789730fe402b3322624a338
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82f25858ff585025ee7f1efbd8d099114b18a07f2cac3fc8f2f20d4967b8fe46d68f61d5b922d7ebb80146645fb6d47aa4788df96feae706c8310e99c8992098
|
|
7
|
+
data.tar.gz: 0333e25614ba5be270978f6b5022ca27e2e4b1ead840e4804aa73b1287f57b5cbc8ee25bf8aa065061abb5e194c069ec62751e9ce281c8c665d9857bcc2578c4
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [1.3.0] - 2026-08-25
|
|
4
|
+
|
|
5
|
+
- [Feature] Read customers, estimates, jobs and job appointments as an Active Record relation:
|
|
6
|
+
`Hcp::Job.all`, `.where`, `.order`, `.limit`, `.includes`, `.find`, `.count` and `.first`,
|
|
7
|
+
walked lazily a page at a time.
|
|
8
|
+
- [Feature] Set the API key once, with `Hcp.key` or `HCP_KEY`, and pass `company_id:` per call.
|
|
9
|
+
- [Feature] Narrow a list by a range — `where(scheduled_at: ..2.days.ago)` — and refuse a
|
|
10
|
+
condition, an order or an expansion Housecall Pro does not take, which it answers by
|
|
11
|
+
ignoring rather than by refusing.
|
|
12
|
+
- [Feature] Raise `Hcp::NotFound` where Housecall Pro has no such record, and
|
|
13
|
+
`Hcp::TooManyRequests`, carrying `reset_at`, where it refuses one for rate. Both descend
|
|
14
|
+
from `Hcp::Error`, so an existing rescue still catches them.
|
|
15
|
+
- [Breaking change] `Hcp::Lead#lead_for`, `#customer_for` and `#uri` are now private, and
|
|
16
|
+
neither `Hcp::Lead` nor `Hcp::Lead::Pipeline` inherits from `Hcp::Resource`, which is now a
|
|
17
|
+
record rather than a holder of credentials. Nothing documented ever called them.
|
|
18
|
+
|
|
1
19
|
## [1.2.4] - 2026-06-17
|
|
2
20
|
|
|
3
21
|
- [Fix] Set event.type when params is nil
|
data/README.md
CHANGED
|
@@ -1,20 +1,133 @@
|
|
|
1
1
|
# The Housecall Pro API Ruby client
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
This release reads. Writing — beyond the leads that were here before it — comes next.
|
|
8
|
+
|
|
9
|
+
## How to install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
gem install hcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or, in a Gemfile, pinned to the current major:
|
|
6
16
|
|
|
7
17
|
```ruby
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
gem 'hcp', '~> 1.3'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`~> major.minor` means `bundle update` never crosses a breaking change.
|
|
22
|
+
|
|
23
|
+
## The key, and the location
|
|
24
|
+
|
|
25
|
+
Set the key once. Left unset, the gem reads `HCP_KEY` from the environment:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Hcp.key = ENV['HCP_KEY']
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
An account with more than one location passes `company_id:` per call, since one process may
|
|
32
|
+
serve several:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Hcp::Job.find id, company_id: company_id
|
|
36
|
+
Hcp::Job.all(company_id: company_id).where(work_status: :scheduled)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Reading
|
|
40
|
+
|
|
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:
|
|
43
|
+
|
|
44
|
+
```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
|
|
52
|
+
```
|
|
53
|
+
|
|
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:
|
|
56
|
+
|
|
57
|
+
```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
|
+
```
|
|
62
|
+
|
|
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`.
|
|
66
|
+
|
|
67
|
+
Nothing comes back unasked. `includes` asks for what Housecall Pro otherwise leaves out:
|
|
13
68
|
|
|
14
|
-
|
|
69
|
+
```ruby
|
|
70
|
+
Hcp::Job.includes(:appointments).limit 20
|
|
71
|
+
```
|
|
72
|
+
|
|
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:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Hcp::Job.where bogus: 1
|
|
78
|
+
# => Hcp::Error: bogus is not one of: scheduled_at, ends_at, customer_id, ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### What each resource reads
|
|
82
|
+
|
|
83
|
+
```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)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Housecall Pro counts money in cents; this gem reads it in dollars, as a `BigDecimal`.
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
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
|
+
```
|
|
107
|
+
|
|
108
|
+
Nothing here sleeps. A caller told to come back later has a queue that can bring the whole job
|
|
109
|
+
back, which is worth more than a worker asleep holding a connection open.
|
|
110
|
+
|
|
111
|
+
## Leads
|
|
112
|
+
|
|
113
|
+
Opening a lead, and moving one through the pipeline, are unchanged:
|
|
15
114
|
|
|
16
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
|
+
|
|
17
121
|
pipeline = Hcp::Lead::Pipeline.new id:, key:, company_id:
|
|
18
122
|
pipeline.update status_name: 'Won'
|
|
19
123
|
```
|
|
20
124
|
|
|
125
|
+
## Webhooks
|
|
126
|
+
|
|
127
|
+
`Hcp::Event` reads a webhook payload, and reaches the network for nothing:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
event = Hcp::Event.new params
|
|
131
|
+
event.type # :job_scheduled
|
|
132
|
+
event.job_id, event.customer_id, event.scheduled_at, event.invoice_amount
|
|
133
|
+
```
|
data/lib/hcp/answer.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# What Housecall Pro answered: the body it carries, or the refusal it stands for.
|
|
3
|
+
class Answer
|
|
4
|
+
# The header Housecall Pro names the epoch a rate limit lifts at.
|
|
5
|
+
RESET_HEADER = 'RateLimit-Reset'
|
|
6
|
+
|
|
7
|
+
# @param response [Net::HTTPResponse] what Housecall Pro sent back.
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@response = response
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash] the record or page Housecall Pro answered with.
|
|
13
|
+
def body
|
|
14
|
+
case @response
|
|
15
|
+
when Net::HTTPSuccess then parsed
|
|
16
|
+
when Net::HTTPNotFound then raise NotFound, message
|
|
17
|
+
when Net::HTTPTooManyRequests then raise TooManyRequests.new(message, reset_at)
|
|
18
|
+
else raise Error, message
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
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
|
+
def message
|
|
27
|
+
error = parsed['error'] || parsed['message']
|
|
28
|
+
error.is_a?(Hash) ? error['message'] : error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_at = (Time.at Integer(@response[RESET_HEADER]) if @response[RESET_HEADER])
|
|
32
|
+
|
|
33
|
+
def parsed = @parsed ||= JSON(@response.body)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a list with the chaining that narrows, orders and cuts it.
|
|
3
|
+
module Chainable
|
|
4
|
+
# A condition Housecall Pro does not take is one it ignores, answering the whole account
|
|
5
|
+
# rather than a page of it, so an unknown one is refused here where it can still be named.
|
|
6
|
+
# @return [Relation] the same list, narrowed by these conditions.
|
|
7
|
+
def where(**conditions)
|
|
8
|
+
only conditions.keys, @type.filters.keys
|
|
9
|
+
narrowed conditions: @conditions.merge(conditions)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Relation] the same list, in this order.
|
|
13
|
+
def order(**sorts)
|
|
14
|
+
only sorts.keys, @type.sorts
|
|
15
|
+
narrowed sorts: sorts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Relation] the same list, stopping after this many records.
|
|
19
|
+
def limit(count) = narrowed limit: count
|
|
20
|
+
|
|
21
|
+
# @return [Relation] the same list, asking Housecall Pro for these beside each record.
|
|
22
|
+
def includes(*names)
|
|
23
|
+
only names, @type.expands
|
|
24
|
+
narrowed expands: @expands | names
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def only(names, allowed)
|
|
30
|
+
unknown = names - allowed
|
|
31
|
+
raise Error, "#{unknown.first} is not one of: #{allowed.join ', '}" if unknown.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def narrowed(conditions: @conditions, sorts: @sorts, limit: @limit, expands: @expands)
|
|
35
|
+
Relation.new type: @type, path: @path, company_id: @company_id, conditions: conditions,
|
|
36
|
+
sorts: sorts, limit: limit, expands: expands
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a class handed its own key, as everything was before the key became global.
|
|
3
|
+
module Keyed
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def headers
|
|
7
|
+
{
|
|
8
|
+
'Authorization' => "Token #{@key}",
|
|
9
|
+
'Content-Type' => 'application/json',
|
|
10
|
+
'X-Company-Id' => @company_id,
|
|
11
|
+
}.compact
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro holds a person's names for.
|
|
3
|
+
module Named
|
|
4
|
+
# @return [String] what they are called, by whichever of their names Housecall Pro holds.
|
|
5
|
+
def name = [ @node['first_name'], @node['last_name'] ].compact_blank.join ' '
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a resource with the entry points that read a list of it, or one record.
|
|
3
|
+
module Queryable
|
|
4
|
+
# @param company_id [String, nil] the location to read as, where the account has several.
|
|
5
|
+
# @return [Relation] every record of this kind the location holds.
|
|
6
|
+
def all(company_id: nil) = Relation.new type: self, company_id: company_id
|
|
7
|
+
|
|
8
|
+
# @return [Relation] the records matching these conditions.
|
|
9
|
+
def where(company_id: nil, **conditions) = all(company_id: company_id).where(**conditions)
|
|
10
|
+
|
|
11
|
+
# @return [Relation] the records in this order.
|
|
12
|
+
def order(company_id: nil, **sorts) = all(company_id: company_id).order(**sorts)
|
|
13
|
+
|
|
14
|
+
# @return [Relation] at most this many records.
|
|
15
|
+
def limit(count, company_id: nil) = all(company_id: company_id).limit(count)
|
|
16
|
+
|
|
17
|
+
# @return [Relation] the records, with these brought back beside each of them.
|
|
18
|
+
def includes(*names, company_id: nil) = all(company_id: company_id).includes(*names)
|
|
19
|
+
|
|
20
|
+
# @return [Integer] how many records of this kind the location holds.
|
|
21
|
+
def count(company_id: nil) = all(company_id: company_id).count
|
|
22
|
+
|
|
23
|
+
# @return [Resource, nil] the first record, or nil where the location holds none.
|
|
24
|
+
def first(company_id: nil) = all(company_id: company_id).first
|
|
25
|
+
|
|
26
|
+
# @return [Resource] the record Housecall Pro files under this ID.
|
|
27
|
+
def find(id, company_id: nil) = all(company_id: company_id).find(id)
|
|
28
|
+
|
|
29
|
+
# @return [Hash] what a list of these may be narrowed by.
|
|
30
|
+
def filters = self::FILTERS
|
|
31
|
+
|
|
32
|
+
# @return [Array<Symbol>] the conditions Housecall Pro takes more than one of.
|
|
33
|
+
def many = self::MANY
|
|
34
|
+
|
|
35
|
+
# @return [Array<Symbol>] what Housecall Pro will put a list of these in order of.
|
|
36
|
+
def sorts = self::SORTS
|
|
37
|
+
|
|
38
|
+
# @return [Array<Symbol>] what these bring back beside themselves where asked to.
|
|
39
|
+
def expands = self::EXPANDS
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro books work for and stamps as the work happens.
|
|
3
|
+
module Scheduled
|
|
4
|
+
# @return [Schedule, nil] when the work is booked for.
|
|
5
|
+
def schedule = record Schedule, 'schedule'
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the pro said they were on their way.
|
|
8
|
+
def on_my_way_at = time 'work_timestamps', 'on_my_way_at'
|
|
9
|
+
|
|
10
|
+
# @return [Time, nil] when the work started.
|
|
11
|
+
def started_at = time 'work_timestamps', 'started_at'
|
|
12
|
+
|
|
13
|
+
# @return [Time, nil] when the work was finished.
|
|
14
|
+
def completed_at = time 'work_timestamps', 'completed_at'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro moves through a workflow.
|
|
3
|
+
module Statused
|
|
4
|
+
# Housecall Pro narrows a list by one set of words and answers with another, so a caller
|
|
5
|
+
# who filters by :in_progress reads :in_progress back rather than 'in progress'.
|
|
6
|
+
STATUSES = {
|
|
7
|
+
'needs scheduling' => :unscheduled,
|
|
8
|
+
'scheduled' => :scheduled,
|
|
9
|
+
'in progress' => :in_progress,
|
|
10
|
+
'complete rated' => :completed,
|
|
11
|
+
'complete unrated' => :completed,
|
|
12
|
+
'user canceled' => :canceled,
|
|
13
|
+
'pro canceled' => :canceled,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# @return [Symbol, nil] where Housecall Pro files the record in its own workflow.
|
|
17
|
+
def work_status = STATUSES[@node['work_status']]
|
|
18
|
+
|
|
19
|
+
# A rating is not a status, so it reads beside one rather than inside it.
|
|
20
|
+
# @return [Boolean] whether the customer rated the work.
|
|
21
|
+
def rated? = @node['work_status'] == 'complete rated'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro stamps with when it was opened and last changed.
|
|
3
|
+
module Timestamped
|
|
4
|
+
# @return [Time, nil] when the record was opened.
|
|
5
|
+
def created_at = time 'created_at'
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the record last changed.
|
|
8
|
+
def updated_at = time 'updated_at'
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/hcp/error.rb
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Raised where Housecall Pro refuses a request for rate.
|
|
3
|
+
class TooManyRequests < Error
|
|
4
|
+
# @param message [String] what Housecall Pro said.
|
|
5
|
+
# @param reset_at [Time, nil] when it will answer again.
|
|
6
|
+
def initialize(message, reset_at)
|
|
7
|
+
super message
|
|
8
|
+
@reset_at = reset_at
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Nothing here sleeps: a caller with a queue can bring the whole job back, which is worth
|
|
12
|
+
# more than a worker asleep holding a connection open.
|
|
13
|
+
# @return [Time, nil] when the limit lifts, where Housecall Pro said.
|
|
14
|
+
attr_reader :reset_at
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/hcp/event.rb
CHANGED
data/lib/hcp/filter.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One condition a list is narrowed by, as the parameters Housecall Pro takes it as.
|
|
3
|
+
class Filter
|
|
4
|
+
# @param bounds [Array<Symbol>] the parameter for each end of a range, or the single one.
|
|
5
|
+
# @param value [Object] a range, or the one value to match.
|
|
6
|
+
def initialize(bounds:, value:)
|
|
7
|
+
@bounds = bounds
|
|
8
|
+
@value = value
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [Hash] the parameters to send, without the end the caller left open.
|
|
12
|
+
def params
|
|
13
|
+
return { @bounds.first => @value } unless @value.is_a? Range
|
|
14
|
+
|
|
15
|
+
refuse
|
|
16
|
+
low, high = @bounds
|
|
17
|
+
{ low => stamp(@value.begin), high => stamp(@value.end) }.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Housecall Pro reads both ends inclusively, so an excluded end would come back anyway.
|
|
23
|
+
def refuse
|
|
24
|
+
raise Error, "#{@bounds.first} takes one value, not a range" if @bounds.one?
|
|
25
|
+
raise Error, "#{@bounds.last} cannot exclude its end" if @value.exclude_end? && @value.end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stamp(value)
|
|
29
|
+
case value
|
|
30
|
+
when Time then value.utc.iso8601
|
|
31
|
+
when Date then value.iso8601
|
|
32
|
+
else value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/hcp/key.rb
ADDED
data/lib/hcp/lead/pipeline.rb
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
module Hcp
|
|
2
|
-
|
|
2
|
+
# Where a Housecall Pro user files a lead while they are still chasing it.
|
|
3
|
+
class Lead::Pipeline
|
|
4
|
+
include Keyed
|
|
5
|
+
|
|
3
6
|
def initialize(id: nil, key:, company_id:)
|
|
4
7
|
@id = id
|
|
5
8
|
@key = key
|
|
6
9
|
@company_id = company_id
|
|
7
10
|
end
|
|
8
11
|
|
|
12
|
+
# Moves the lead to the status going by this name.
|
|
13
|
+
# @param status_name [String] the status as the account names it, such as 'Won'.
|
|
9
14
|
def update(status_name:)
|
|
10
15
|
status = find_status_by name: status_name
|
|
11
|
-
raise Error, "Status #{status_name} not found for lead #{@id}" unless status
|
|
12
|
-
|
|
13
16
|
payload = { resource_type: 'lead', resource_id: @id, status_id: status['id'] }.to_json
|
|
14
17
|
response = Net::HTTP.put uri, payload, headers
|
|
15
18
|
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
data/lib/hcp/lead.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Hcp
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
# Somebody who asked a Housecall Pro user for work, before there is a job.
|
|
3
|
+
class Lead
|
|
4
|
+
include Keyed
|
|
5
|
+
|
|
6
|
+
attr_reader :id, :customer_id
|
|
4
7
|
|
|
5
8
|
def initialize(id: nil, customer_id: nil, key:, company_id:)
|
|
6
9
|
@id = id
|
|
@@ -9,6 +12,8 @@ module Hcp
|
|
|
9
12
|
@customer_id = customer_id
|
|
10
13
|
end
|
|
11
14
|
|
|
15
|
+
# Opens the lead on Housecall Pro, and keeps what it filed the lead and customer under.
|
|
16
|
+
# @param params [Hash] :name, :email, :phone, :address, :note and :source.
|
|
12
17
|
def create(params = {})
|
|
13
18
|
response = Net::HTTP.post uri, lead_for(params).to_json, headers
|
|
14
19
|
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|
|
@@ -18,10 +23,12 @@ module Hcp
|
|
|
18
23
|
raise Error, error
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
private
|
|
27
|
+
|
|
21
28
|
def lead_for(params = {})
|
|
22
29
|
{
|
|
23
30
|
customer: customer_for(params), address: params[:address],
|
|
24
|
-
lead_source: params[:source], note: params[:note]
|
|
31
|
+
lead_source: params[:source], note: params[:note],
|
|
25
32
|
}.compact_blank
|
|
26
33
|
end
|
|
27
34
|
|
data/lib/hcp/relation.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Every record of one kind a Housecall Pro account holds, walked a page at a time.
|
|
3
|
+
class Relation
|
|
4
|
+
include Chainable, Enumerable
|
|
5
|
+
|
|
6
|
+
# Records a page: the most Housecall Pro answers with, and more than it refuses.
|
|
7
|
+
PAGE = 200
|
|
8
|
+
|
|
9
|
+
# @param type [Class] what each record is read as.
|
|
10
|
+
# @param path [String] where Housecall Pro keeps them, under the host.
|
|
11
|
+
# @param company_id [String, nil] the location to read them as.
|
|
12
|
+
def initialize(type:, path: nil, company_id: nil, conditions: {}, sorts: {}, limit: nil,
|
|
13
|
+
expands: [])
|
|
14
|
+
@type = type
|
|
15
|
+
@path = path || type.path
|
|
16
|
+
@company_id = company_id
|
|
17
|
+
@conditions = conditions
|
|
18
|
+
@sorts = sorts
|
|
19
|
+
@limit = limit
|
|
20
|
+
@expands = expands
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Nothing is read until the walk starts, and a page only once the one before it runs out.
|
|
24
|
+
# @return [Enumerator, Relation] every record the list holds, or the list once walked.
|
|
25
|
+
def each(&block)
|
|
26
|
+
return walk unless block
|
|
27
|
+
|
|
28
|
+
walk.each(&block)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Shadows Enumerable#find the way Active Record does: a record is reached by the ID
|
|
33
|
+
# Housecall Pro files it under, not by asking every record whether it is the one.
|
|
34
|
+
# @param id [String] the Housecall Pro ID.
|
|
35
|
+
# @return [Resource] the record, raising Hcp::NotFound where there is none.
|
|
36
|
+
def find(id) = record_for read("#{@path}/#{id}")
|
|
37
|
+
|
|
38
|
+
# A page of one carries the total beside it, so this is one small read however long the
|
|
39
|
+
# list is.
|
|
40
|
+
# @return [Integer] how many records the list holds.
|
|
41
|
+
def count = @count ||= [ read(@path, page_size: 1)['total_items'], @limit ].compact.min
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def walk
|
|
46
|
+
Enumerator.new do |yielder|
|
|
47
|
+
seen = 0
|
|
48
|
+
(1..).each do |page|
|
|
49
|
+
body = read @path, page: page, page_size: [ @limit, PAGE ].compact.min
|
|
50
|
+
body.fetch(@type.key).each do |node|
|
|
51
|
+
yielder << record_for(node)
|
|
52
|
+
break if (seen += 1) == @limit
|
|
53
|
+
end
|
|
54
|
+
break if seen == @limit || page >= body.fetch('total_pages', page)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read(path, paging = {})
|
|
60
|
+
Request.new(path: path, params: params.merge(paging), company_id: @company_id).body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def params
|
|
64
|
+
@conditions.flat_map { |name, value| filter(name, value).to_a }.to_h.
|
|
65
|
+
merge sort_by: @sorts.keys.first, sort_direction: @sorts.values.first,
|
|
66
|
+
expand: @expands.presence
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def filter(name, value)
|
|
70
|
+
bounds = @type.filters.fetch name
|
|
71
|
+
Filter.new(bounds: bounds, value: @type.many.include?(name) ? Array(value) : value).params
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_for(node) = @type.new node: node, company_id: @company_id
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/hcp/request.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One read from Housecall Pro.
|
|
3
|
+
class Request
|
|
4
|
+
# Where Housecall Pro answers.
|
|
5
|
+
HOST = 'https://api.housecallpro.com'
|
|
6
|
+
|
|
7
|
+
# @param path [String] what to read, under the host.
|
|
8
|
+
# @param params [Hash] the query to read it with.
|
|
9
|
+
# @param company_id [String, nil] the location to read it as.
|
|
10
|
+
def initialize(path:, params: {}, company_id: nil)
|
|
11
|
+
@path = path
|
|
12
|
+
@params = params
|
|
13
|
+
@company_id = company_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Hash] what Housecall Pro answered.
|
|
17
|
+
def body = Answer.new(Net::HTTP.get_response(uri, headers)).body
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def uri = URI [ "#{HOST}/#{@path}", @params.compact.to_query ].compact_blank.join '?'
|
|
22
|
+
|
|
23
|
+
def headers
|
|
24
|
+
{
|
|
25
|
+
'Authorization' => "Token #{Hcp.key}",
|
|
26
|
+
'Content-Type' => 'application/json',
|
|
27
|
+
'X-Company-Id' => @company_id,
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/hcp/resource.rb
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
1
|
module Hcp
|
|
2
|
+
# A record as Housecall Pro answered it.
|
|
2
3
|
class Resource
|
|
3
|
-
|
|
4
|
+
class << self
|
|
5
|
+
# Declares a reader for what Housecall Pro answers under a name.
|
|
6
|
+
# @!macro [attach] attribute
|
|
7
|
+
# @!method $1
|
|
8
|
+
# @return [String, nil] the $1, as Housecall Pro answers it.
|
|
9
|
+
def attribute(name, key = name) = define_method(name) { @node[key.to_s] }
|
|
10
|
+
|
|
11
|
+
# Declares a reader for a moment Housecall Pro stamps.
|
|
12
|
+
# @!macro [attach] timestamp
|
|
13
|
+
# @!method $1
|
|
14
|
+
# @return [Time, nil] the moment Housecall Pro answers as $1.
|
|
15
|
+
def timestamp(name, key = name) = define_method(name) { time key.to_s }
|
|
16
|
+
|
|
17
|
+
# Declares a reader for a sum Housecall Pro counts in cents.
|
|
18
|
+
# @!macro [attach] amount
|
|
19
|
+
# @!method $1
|
|
20
|
+
# @return [BigDecimal, nil] the $1, which Housecall Pro counts in cents.
|
|
21
|
+
def amount(name, key = name) = define_method(name) { money key.to_s }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param node [Hash] the record as Housecall Pro answered it.
|
|
25
|
+
# @param company_id [String, nil] the location it was read as.
|
|
26
|
+
def initialize(node: {}, company_id: nil)
|
|
27
|
+
@node = node
|
|
28
|
+
@company_id = company_id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] the ID Housecall Pro files the record under.
|
|
32
|
+
def id = @node['id']
|
|
4
33
|
|
|
5
34
|
private
|
|
6
35
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
36
|
+
# Built from the record's own path rather than its class, so a nested collection sits
|
|
37
|
+
# under its parent however deep Housecall Pro put it.
|
|
38
|
+
def nested(type, segment)
|
|
39
|
+
Relation.new type: type, path: "#{path}/#{segment}", company_id: @company_id
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def path = "#{self.class.path}/#{id}"
|
|
43
|
+
|
|
44
|
+
def time(*keys)
|
|
45
|
+
value = @node.dig(*keys)
|
|
46
|
+
Time.iso8601 value if value.present?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Housecall Pro counts money in cents, and a caller reads it in dollars.
|
|
50
|
+
def money(key) = (BigDecimal(@node[key].to_s) / 100 if @node[key])
|
|
51
|
+
|
|
52
|
+
def record(type, key) = (type.new node: @node[key], company_id: @company_id if @node[key])
|
|
53
|
+
|
|
54
|
+
def records(type, key)
|
|
55
|
+
Array(@node[key]).map { |node| type.new node: node, company_id: @company_id }
|
|
13
56
|
end
|
|
14
57
|
end
|
|
15
58
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Where a customer is billed, or where the work happens.
|
|
3
|
+
class Address < Resource
|
|
4
|
+
# What Housecall Pro calls a page of a customer's addresses.
|
|
5
|
+
def self.key = 'addresses'
|
|
6
|
+
|
|
7
|
+
attribute :street
|
|
8
|
+
attribute :street_line_2
|
|
9
|
+
attribute :city
|
|
10
|
+
attribute :state
|
|
11
|
+
attribute :zip
|
|
12
|
+
attribute :country
|
|
13
|
+
|
|
14
|
+
# @return [Float, nil] how far north the address is, where Housecall Pro placed it.
|
|
15
|
+
attribute :latitude
|
|
16
|
+
|
|
17
|
+
# @return [Float, nil] how far east the address is, where Housecall Pro placed it.
|
|
18
|
+
attribute :longitude
|
|
19
|
+
|
|
20
|
+
# @return [Symbol, nil] :billing or :service.
|
|
21
|
+
def type = @node['type']&.to_sym
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Whoever the work is for.
|
|
3
|
+
class Customer < Resource
|
|
4
|
+
extend Queryable
|
|
5
|
+
include Named, Timestamped
|
|
6
|
+
|
|
7
|
+
# A customer is narrowed by one free-text search rather than by named fields.
|
|
8
|
+
FILTERS = { q: %i[q], location_ids: %i[location_ids] }
|
|
9
|
+
|
|
10
|
+
# The conditions Housecall Pro takes more than one of, and refuses one of.
|
|
11
|
+
MANY = %i[location_ids]
|
|
12
|
+
|
|
13
|
+
# What Housecall Pro will put a list of customers in order of.
|
|
14
|
+
SORTS = %i[created_at updated_at]
|
|
15
|
+
|
|
16
|
+
# What a customer brings back beside themselves where they are asked to.
|
|
17
|
+
EXPANDS = %i[attachments do_not_service]
|
|
18
|
+
|
|
19
|
+
# Where Housecall Pro keeps them.
|
|
20
|
+
def self.path = 'customers'
|
|
21
|
+
|
|
22
|
+
# What Housecall Pro calls a page of them.
|
|
23
|
+
def self.key = 'customers'
|
|
24
|
+
|
|
25
|
+
attribute :first_name
|
|
26
|
+
attribute :last_name
|
|
27
|
+
attribute :email
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] the business the customer is, where they are one.
|
|
30
|
+
attribute :company
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] where the customer came from.
|
|
33
|
+
attribute :lead_source
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Housecall Pro holds three numbers, and a caller wants whichever one there is.
|
|
37
|
+
# @return [String, nil] the number the customer is reached on first.
|
|
38
|
+
def phone = @node['mobile_number'] || @node['home_number'] || @node['work_number']
|
|
39
|
+
|
|
40
|
+
# @return [Symbol, nil] :homeowner, or whatever else Housecall Pro takes the customer for.
|
|
41
|
+
def kind = @node['kind']&.to_sym
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] whether the customer agreed to hear from the pro.
|
|
44
|
+
def notifications_enabled? = @node['notifications_enabled']
|
|
45
|
+
|
|
46
|
+
# @return [Array<String>] what the customer is tagged with.
|
|
47
|
+
def tags = Array(@node['tags'])
|
|
48
|
+
|
|
49
|
+
# @return [Relation] every address the customer is billed or served at.
|
|
50
|
+
def addresses = nested Address, 'addresses'
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One of the things a customer was offered on an estimate, priced on its own.
|
|
3
|
+
class Estimate::Option < Resource
|
|
4
|
+
include Timestamped
|
|
5
|
+
|
|
6
|
+
attribute :name
|
|
7
|
+
attribute :option_number
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] whether the customer has answered, and how.
|
|
10
|
+
attribute :approval_status
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] where Housecall Pro files the option in its own workflow.
|
|
13
|
+
attribute :status
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] what the pro said when they sent it.
|
|
16
|
+
attribute :message_from_pro
|
|
17
|
+
|
|
18
|
+
amount :total_amount
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] what the option is tagged with.
|
|
21
|
+
def tags = Array(@node['tags'])
|
|
22
|
+
|
|
23
|
+
# @return [Array<Note>] what the pros wrote on the option.
|
|
24
|
+
def notes = records Note, 'notes'
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Work a Housecall Pro user offered to do, priced as one option or several.
|
|
3
|
+
class Estimate < Resource
|
|
4
|
+
extend Queryable
|
|
5
|
+
include Scheduled, Statused, Timestamped
|
|
6
|
+
|
|
7
|
+
# What a list of estimates may be narrowed by, against the parameters Housecall Pro takes.
|
|
8
|
+
FILTERS = {
|
|
9
|
+
scheduled_at: %i[scheduled_start_min scheduled_start_max],
|
|
10
|
+
ends_at: %i[scheduled_end_min scheduled_end_max],
|
|
11
|
+
customer_id: %i[customer_id],
|
|
12
|
+
employee_ids: %i[employee_ids],
|
|
13
|
+
work_status: %i[work_status],
|
|
14
|
+
location_ids: %i[location_ids],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# The conditions Housecall Pro takes more than one of, and refuses one of.
|
|
18
|
+
MANY = %i[employee_ids work_status location_ids]
|
|
19
|
+
|
|
20
|
+
# What Housecall Pro will put a list of estimates in order of.
|
|
21
|
+
SORTS = %i[created_at updated_at id]
|
|
22
|
+
|
|
23
|
+
# What an estimate brings back beside itself where it is asked to.
|
|
24
|
+
EXPANDS = %i[attachments]
|
|
25
|
+
|
|
26
|
+
# Where Housecall Pro keeps them.
|
|
27
|
+
def self.path = 'estimates'
|
|
28
|
+
|
|
29
|
+
# What Housecall Pro calls a page of them.
|
|
30
|
+
def self.key = 'estimates'
|
|
31
|
+
|
|
32
|
+
attribute :estimate_number
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] where the work came from.
|
|
35
|
+
attribute :lead_source
|
|
36
|
+
|
|
37
|
+
# @return [Customer, nil] whose estimate it is.
|
|
38
|
+
def customer = record Customer, 'customer'
|
|
39
|
+
|
|
40
|
+
# @return [Address, nil] where the work would happen.
|
|
41
|
+
def address = record Address, 'address'
|
|
42
|
+
|
|
43
|
+
# @return [Array<Employee>] the pros the estimate is assigned to.
|
|
44
|
+
def assigned_employees = records Employee, 'assigned_employees'
|
|
45
|
+
|
|
46
|
+
# @return [Array<Estimate::Option>] what the customer was offered, priced.
|
|
47
|
+
def options = records Estimate::Option, 'options'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One visit a job is booked for, where the work takes more than a single trip.
|
|
3
|
+
class Job::Appointment < Resource
|
|
4
|
+
# What Housecall Pro calls a list of them.
|
|
5
|
+
def self.key = 'appointments'
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the visit is booked to start.
|
|
8
|
+
timestamp :starts_at, :start_time
|
|
9
|
+
|
|
10
|
+
# @return [Time, nil] when the visit is booked to end.
|
|
11
|
+
timestamp :ends_at, :end_time
|
|
12
|
+
|
|
13
|
+
# @return [Integer, nil] how many minutes wide the arrival window is.
|
|
14
|
+
attribute :arrival_window, :arrival_window_minutes
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] whether the visit is booked for a day rather than for a time.
|
|
17
|
+
def anytime? = @node['anytime']
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] the IDs of the pros sent on the visit.
|
|
20
|
+
def dispatched_employee_ids = Array(@node['dispatched_employees_ids'])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# A bill raised for a job.
|
|
3
|
+
class Job::Invoice < Resource
|
|
4
|
+
# What Housecall Pro calls a list of them.
|
|
5
|
+
def self.key = 'invoices'
|
|
6
|
+
|
|
7
|
+
attribute :invoice_number
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] where Housecall Pro files the invoice in its own workflow.
|
|
10
|
+
attribute :status
|
|
11
|
+
|
|
12
|
+
amount :amount
|
|
13
|
+
amount :due_amount
|
|
14
|
+
|
|
15
|
+
# @return [Time, nil] when the invoice was sent.
|
|
16
|
+
timestamp :sent_at
|
|
17
|
+
|
|
18
|
+
# @return [Time, nil] when the invoice was paid.
|
|
19
|
+
timestamp :paid_at
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Work a Housecall Pro user accepted: what it is, who it is for, and what it comes to.
|
|
3
|
+
class Job < Resource
|
|
4
|
+
extend Queryable
|
|
5
|
+
include Scheduled, Statused, Timestamped
|
|
6
|
+
|
|
7
|
+
# What a list of jobs may be narrowed by, against the parameters Housecall Pro takes.
|
|
8
|
+
FILTERS = {
|
|
9
|
+
scheduled_at: %i[scheduled_start_min scheduled_start_max],
|
|
10
|
+
ends_at: %i[scheduled_end_min scheduled_end_max],
|
|
11
|
+
customer_id: %i[customer_id],
|
|
12
|
+
employee_ids: %i[employee_ids],
|
|
13
|
+
work_status: %i[work_status],
|
|
14
|
+
location_ids: %i[location_ids],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# The conditions Housecall Pro takes more than one of, and refuses one of.
|
|
18
|
+
MANY = %i[employee_ids work_status location_ids]
|
|
19
|
+
|
|
20
|
+
# What Housecall Pro will put a list of jobs in order of.
|
|
21
|
+
SORTS = %i[created_at updated_at invoice_number id description work_status]
|
|
22
|
+
|
|
23
|
+
# What a job brings back beside itself where it is asked to.
|
|
24
|
+
EXPANDS = %i[attachments appointments]
|
|
25
|
+
|
|
26
|
+
# Where Housecall Pro keeps them.
|
|
27
|
+
def self.path = 'jobs'
|
|
28
|
+
|
|
29
|
+
# What Housecall Pro calls a page of them.
|
|
30
|
+
def self.key = 'jobs'
|
|
31
|
+
|
|
32
|
+
attribute :invoice_number
|
|
33
|
+
attribute :description
|
|
34
|
+
attribute :lead_source
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] the ID of the estimate the job was won with.
|
|
37
|
+
attribute :original_estimate_id
|
|
38
|
+
|
|
39
|
+
amount :subtotal
|
|
40
|
+
amount :total_amount
|
|
41
|
+
amount :outstanding_balance
|
|
42
|
+
|
|
43
|
+
# @return [Time, nil] when the job was locked against further changes.
|
|
44
|
+
timestamp :locked_at
|
|
45
|
+
|
|
46
|
+
# @return [Time, nil] when the customer canceled the job.
|
|
47
|
+
timestamp :canceled_at
|
|
48
|
+
|
|
49
|
+
# @return [Array<String>] what the job is tagged with.
|
|
50
|
+
def tags = Array(@node['tags'])
|
|
51
|
+
|
|
52
|
+
# @return [Customer, nil] whose job it is.
|
|
53
|
+
def customer = record Customer, 'customer'
|
|
54
|
+
|
|
55
|
+
# @return [Address, nil] where the work happens.
|
|
56
|
+
def address = record Address, 'address'
|
|
57
|
+
|
|
58
|
+
# @return [Array<Note>] what the pros wrote on the job.
|
|
59
|
+
def notes = records Note, 'notes'
|
|
60
|
+
|
|
61
|
+
# @return [Array<Employee>] the pros the job is assigned to.
|
|
62
|
+
def assigned_employees = records Employee, 'assigned_employees'
|
|
63
|
+
|
|
64
|
+
# @return [Relation] the visits the job is booked for.
|
|
65
|
+
def appointments = nested Job::Appointment, 'appointments'
|
|
66
|
+
|
|
67
|
+
# @return [Relation] what the job is billed as.
|
|
68
|
+
def line_items = nested LineItem, 'line_items'
|
|
69
|
+
|
|
70
|
+
# @return [Relation] the invoices raised for the job.
|
|
71
|
+
def invoices = nested Job::Invoice, 'invoices'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One line of what a job or an estimate option comes to.
|
|
3
|
+
class LineItem < Resource
|
|
4
|
+
# Housecall Pro answers a job's line items under `data` rather than under their own name.
|
|
5
|
+
def self.key = 'data'
|
|
6
|
+
|
|
7
|
+
attribute :name
|
|
8
|
+
attribute :description
|
|
9
|
+
|
|
10
|
+
# @return [Float, nil] how many of it, to two decimal places.
|
|
11
|
+
attribute :quantity
|
|
12
|
+
|
|
13
|
+
amount :unit_price
|
|
14
|
+
amount :unit_cost
|
|
15
|
+
amount :amount
|
|
16
|
+
|
|
17
|
+
# @return [Symbol, nil] :materials, :labor, or one of the gratuities and discounts.
|
|
18
|
+
def kind = @node['kind']&.tr(' ', '_')&.to_sym
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# When work is booked for, and how long a window the customer was given.
|
|
3
|
+
class Schedule < Resource
|
|
4
|
+
# @return [Time, nil] when the work is booked to start.
|
|
5
|
+
timestamp :starts_at, :scheduled_start
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the work is booked to end.
|
|
8
|
+
timestamp :ends_at, :scheduled_end
|
|
9
|
+
|
|
10
|
+
# @return [String, nil] the IANA zone to show a customer a time in.
|
|
11
|
+
attribute :time_zone
|
|
12
|
+
|
|
13
|
+
# @return [Integer, nil] how many minutes wide the arrival window is.
|
|
14
|
+
attribute :arrival_window
|
|
15
|
+
|
|
16
|
+
# Housecall Pro leaves these out unless a list was asked to bring them back, so a job read
|
|
17
|
+
# without `includes(:appointments)` has none of them rather than none at all.
|
|
18
|
+
# @return [Array<Job::Appointment>] the visits the work is booked for.
|
|
19
|
+
def appointments = records Job::Appointment, 'appointments'
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/hcp/version.rb
CHANGED
data/lib/hcp.rb
CHANGED
|
@@ -1,13 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
require 'bigdecimal'
|
|
2
|
+
require 'date'
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'net/http'
|
|
5
|
+
require 'time'
|
|
5
6
|
|
|
7
|
+
# Only the Active Support files whose methods are used, rather than the whole of it: a name
|
|
8
|
+
# Housecall Pro holds nothing for arrives as readily empty as null, and its queries carry
|
|
9
|
+
# arrays in the bracketed form `to_query` writes.
|
|
6
10
|
require 'active_support/core_ext/enumerable'
|
|
7
11
|
require 'active_support/core_ext/object/blank'
|
|
12
|
+
require 'active_support/core_ext/object/to_query'
|
|
8
13
|
|
|
14
|
+
require 'hcp/version'
|
|
9
15
|
require 'hcp/error'
|
|
16
|
+
require 'hcp/errors/not_found'
|
|
17
|
+
require 'hcp/errors/too_many_requests'
|
|
18
|
+
require 'hcp/key'
|
|
19
|
+
|
|
20
|
+
# Answer before Request, which reads one, and Filter before Relation, which writes them.
|
|
21
|
+
require 'hcp/answer'
|
|
22
|
+
require 'hcp/request'
|
|
23
|
+
require 'hcp/filter'
|
|
24
|
+
require 'hcp/concerns/chainable'
|
|
25
|
+
require 'hcp/relation'
|
|
26
|
+
require 'hcp/concerns/queryable'
|
|
10
27
|
require 'hcp/resource'
|
|
28
|
+
|
|
29
|
+
# Every concern before the records including it, and Schedule before Scheduled, which reads one.
|
|
30
|
+
require 'hcp/concerns/named'
|
|
31
|
+
require 'hcp/concerns/timestamped'
|
|
32
|
+
require 'hcp/concerns/statused'
|
|
33
|
+
require 'hcp/resources/schedule'
|
|
34
|
+
require 'hcp/concerns/scheduled'
|
|
35
|
+
|
|
36
|
+
require 'hcp/resources/address'
|
|
37
|
+
require 'hcp/resources/employee'
|
|
38
|
+
require 'hcp/resources/line_item'
|
|
39
|
+
require 'hcp/resources/note'
|
|
40
|
+
require 'hcp/resources/customer'
|
|
41
|
+
|
|
42
|
+
# A job before the collections named under it, which its constant has to exist for.
|
|
43
|
+
require 'hcp/resources/job'
|
|
44
|
+
require 'hcp/resources/job/appointment'
|
|
45
|
+
require 'hcp/resources/job/invoice'
|
|
46
|
+
|
|
47
|
+
require 'hcp/resources/estimate'
|
|
48
|
+
require 'hcp/resources/estimate/option'
|
|
49
|
+
|
|
50
|
+
require 'hcp/concerns/keyed'
|
|
11
51
|
require 'hcp/lead'
|
|
12
52
|
require 'hcp/lead/pipeline'
|
|
13
53
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -23,6 +23,90 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop-rails-omakase
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: webmock
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: yard
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
26
110
|
description: Housecall Pro API
|
|
27
111
|
email:
|
|
28
112
|
- claudiob@users.noreply.github.com
|
|
@@ -30,23 +114,49 @@ executables: []
|
|
|
30
114
|
extensions: []
|
|
31
115
|
extra_rdoc_files: []
|
|
32
116
|
files:
|
|
117
|
+
- ".yardopts"
|
|
33
118
|
- CHANGELOG.md
|
|
34
119
|
- LICENSE.txt
|
|
35
120
|
- README.md
|
|
36
121
|
- lib/hcp.rb
|
|
122
|
+
- lib/hcp/answer.rb
|
|
123
|
+
- lib/hcp/concerns/chainable.rb
|
|
124
|
+
- lib/hcp/concerns/keyed.rb
|
|
125
|
+
- lib/hcp/concerns/named.rb
|
|
126
|
+
- lib/hcp/concerns/queryable.rb
|
|
127
|
+
- lib/hcp/concerns/scheduled.rb
|
|
128
|
+
- lib/hcp/concerns/statused.rb
|
|
129
|
+
- lib/hcp/concerns/timestamped.rb
|
|
37
130
|
- lib/hcp/error.rb
|
|
131
|
+
- lib/hcp/errors/not_found.rb
|
|
132
|
+
- lib/hcp/errors/too_many_requests.rb
|
|
38
133
|
- lib/hcp/event.rb
|
|
134
|
+
- lib/hcp/filter.rb
|
|
135
|
+
- lib/hcp/key.rb
|
|
39
136
|
- lib/hcp/lead.rb
|
|
40
137
|
- lib/hcp/lead/pipeline.rb
|
|
138
|
+
- lib/hcp/relation.rb
|
|
139
|
+
- lib/hcp/request.rb
|
|
41
140
|
- lib/hcp/resource.rb
|
|
141
|
+
- lib/hcp/resources/address.rb
|
|
142
|
+
- lib/hcp/resources/customer.rb
|
|
143
|
+
- lib/hcp/resources/employee.rb
|
|
144
|
+
- lib/hcp/resources/estimate.rb
|
|
145
|
+
- lib/hcp/resources/estimate/option.rb
|
|
146
|
+
- lib/hcp/resources/job.rb
|
|
147
|
+
- lib/hcp/resources/job/appointment.rb
|
|
148
|
+
- lib/hcp/resources/job/invoice.rb
|
|
149
|
+
- lib/hcp/resources/line_item.rb
|
|
150
|
+
- lib/hcp/resources/note.rb
|
|
151
|
+
- lib/hcp/resources/schedule.rb
|
|
42
152
|
- lib/hcp/version.rb
|
|
43
153
|
homepage: https://github.com/claudiob/hcp
|
|
44
154
|
licenses:
|
|
45
155
|
- MIT
|
|
46
156
|
metadata:
|
|
47
157
|
homepage_uri: https://github.com/claudiob/hcp
|
|
48
|
-
|
|
49
|
-
|
|
158
|
+
changelog_uri: https://github.com/claudiob/hcp/blob/main/CHANGELOG.md
|
|
159
|
+
documentation_uri: https://rubydoc.info/gems/hcp
|
|
50
160
|
rdoc_options: []
|
|
51
161
|
require_paths:
|
|
52
162
|
- lib
|