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:

 = Acme::.new credentials  # => #<Acme::Account>

business = .business              # => #<Acme::Business>
business.id                              # => '42'
business.name                            # => 'Acme, Inc.'
business.phone                           # => '5555555555'
business.subsidiaries                    # => [#<Acme::Business>, ...]

jobs = .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 = .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 = .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 = .technicians.first   # => #<Acme::Technician>
technician.id                            # => 't1'
technician.name                          # => 'Grace'
technician.surname                       # => 'Hopper'

week = .visits.between(monday, sunday).of(technician.id)
week.ids                                 # => ['v1', 'v2', ...]

free = .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 = .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:: < Company::
  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.