Company
One vocabulary for the records a field-service business keeps, whichever platform keeps them.
An account opens the business behind a set of credentials and the jobs, visits and leads it
holds; a gem that speaks to one platform subclasses Company::Account and the kinds it answers
for, and every reader is named here, once.
How to install
To install on your system, run
gem install company
To use inside a bundled Ruby project, add this line to the Gemfile:
gem 'company', '~> 2.0'
Semantic Versioning promises that ~> major.minor never crosses a breaking change, so the pin
takes every 2.x release and stops short of 3.0.
How it reads
Given a gem that answers the vocabulary -- call it Acme -- a caller never learns how Acme's platform spells a field, pages a list or counts its money:
account = Acme::Account.new credentials # => #<Acme::Account>
business = account.business # => #<Acme::Business>
business.id # => '42'
business.name # => 'Acme, Inc.'
business.phone # => '5555555555'
business.subsidiaries # => [#<Acme::Business>, ...]
jobs = account.jobs.past(3.months) # => #<Acme::Jobs>
jobs.ids # => ['j1', 'j2', ...]
job = jobs.first
job.id # => 'j1'
job.description # => 'Repair'
job.notes # => 'Ring twice'
job.created_at # => 2026-09-03 06:00:00 UTC
job.scheduled_at # => 2026-09-10 01:00:00 UTC
job.completed_at # => nil
job.amount # => 19.99
quote = job.quote # => #<Acme::Quote>, or nil where the job was won without one
quote.id # => 'q1'
quote.amount # => 24.99
line = job.lines.first # => #<Acme::Line>
line.id # => 'l1'
line.name # => 'Labor'
line.description # => 'Hours to fix the issue'
line.quantity # => 1.5
line.amount # => 24.99
location = job.location # => #<Acme::Location>
location.id # => 'a1'
location.street # => '100 Acme Circle'
location.city # => 'Springfield'
location.zip # => '98920'
location.latitude # => 45.2335
location.longitude # => -9.1234
customer = location.customer # => #<Acme::Customer>
customer.id # => 'c1'
customer.name # => 'Jane'
customer.surname # => 'Qi'
customer.email # => '[email protected]'
customer.phone # => '5555555666'
visits = account.visits.upcoming(1.day) # => #<Acme::Visits>, stops of jobs and of leads
visits.ids # => ['v1', 'v2', ...]
visit = visits.first # => #<Acme::Visit>
visit.id # => 'v1'
visit.description # => 'Service appointment'
visit.starts_at # => 2026-09-10 01:00:00 UTC
visit.ends_at # => 2026-09-10 02:00:00 UTC
visit.anytime? # => false
visit.location # => #<Acme::Location>, or nil where it is nowhere
visit.job # => job, or nil where no job was booked for it
visit.lead # => lead, or nil where no lead was booked for it
visit.technicians # => [#<Acme::Technician>, ...]
visits.for_jobs # => only the stops of jobs
visits.for_leads # => only the stops of leads
visits.includes(location: :customer) # => the same list, whatever the platform charges
booked = account.visits.create name: 'Jane', surname: 'Qi', phone: '5555555666',
email: '[email protected]', address: { street: '100 Acme Circle', zip: '98920' },
description: 'Repair', notes: 'Estimate $20-$30', source: 'Website',
starts_at: 1.day.from_now, ends_at: 1.day.from_now + 1.hour, technicians: [technician]
booked.lead # => #<Acme::Lead>, opened with the stop
booked.lead.location # => #<Acme::Location>, where to go
technician = account.technicians.first # => #<Acme::Technician>
technician.id # => 't1'
technician.name # => 'Grace'
technician.surname # => 'Hopper'
week = account.visits.between(monday, sunday).of(technician.id)
week.ids # => ['v1', 'v2', ...]
free = account.windows.between(monday, sunday).of(technician.id)
free.first.starts_at # => 2026-09-16 13:00:00 UTC
free.first.ends_at # => 2026-09-16 17:00:00 UTC
lead = account.leads.create name: 'Jane', surname: 'Qi', phone: '5555555666',
email: '[email protected]', address: { street: '100 Acme Circle', zip: '98920' },
description: 'Repair', notes: 'Estimate $20–$30', source: 'Website'
lead.id # => 'd1'
lead.customer # => #<Acme::Customer>
A moment reads as a Time, an amount as dollars in a BigDecimal, a phone as the ten digits to
dial, and a field the platform holds nothing for as nil. A list is walked a page at a time, as
far as it goes or narrowed: to a window measured from now, to the technician the records are
booked for, or to one kind of stop. The narrowings compose in any order, so one technician's
week reads the same whichever is asked for first.
A visit is any booked time: somebody is somewhere for an hour. That is what a schedule is read for -- who is where, and when -- so a visit says where it is without being asked what it was booked for, and a caller never reaches through a job to find an address.
job and lead answer why, and either may be absent. A stop to look at something nobody has
priced yet -- Jobber calls it an assessment, Housecall Pro an estimate -- names a lead and no
job. An hour blocked out on a calendar names neither, and occupies the technician's day just
the same. quote stays the price, which is the other half of what Housecall Pro files as one
record.
for_jobs, for_leads and for_work narrow by what a stop was booked against: one kind, the
other, or both and not the hours held around them.
A window is the other half of the same question: not the hours somebody is out, but the ones they are not. It names no work and nobody going, a list of them having been narrowed to one technician already, and it is as long as it is -- cut it into offerable pieces where an offer is being made, since only the caller making one knows how long it needs them to be.
A platform that does not work free time out for itself answers windows with
NotImplementedError rather than with none. An empty week and a fully booked one are the same
shape, so a caller reading none as none would quietly stop offering that business at all.
Answering as a gem
A gem subclasses Company::Account and answers the readers its platform offers, and subclasses
a kind wherever its platform spells a key otherwise than the vocabulary:
class Acme::Account < Company::Account
def business = Business.new node: read('company')
def jobs = Jobs.new account: self
end
class Acme::Business < Company::Business
def self.keys = { phone: :phone_number }
end
class Acme::Jobs < Company::Collection
def each = ... # walk the platform's pages, yielding a Job each
def between(from, to) = ... # the same list, narrowed to what starts between the two
end
Company::Business.node_keys then answers [:id, :name, :phone_number]: exactly what to ask
the platform for. A reader the gem leaves out raises NotImplementedError naming the gem; leads
it leaves out refuse to file one, and so do the visits. includes a gem leaves out answers the
same list: a platform that hands a record over whole has nothing to bring back beside it, so a
caller names what it reads without knowing which kind of platform it is talking to. of,
for_jobs and for_leads a gem leaves out still answer: Company::Selection walks the list and lets through
what the rule keeps, so only a platform that can put the question to its server writes the
method, and only to save the requests the walk would spend. The least a gem writes is under test/acme, and the test that
runs every reader through it, test/company/acme_test.rb, reads as a tutorial.
Errors
Every error a gem raises descends from Company::Error, so one rescue catches the lot, and one
for a request held to a rate from Company::Throttled, so one retry covers every platform.
Development
bin/setup gets a clone working, bin/console opens a prompt with the library loaded, and
bundle exec rake runs the suite, the linter and the two size limits -- which is what CI runs
too.
Reference
The API reference is built from what RubyGems holds, at rubydoc.info/gems/company. The source is at github.com/claudiob/company.
License
MIT, see LICENSE.txt.