A Rails application for running the billing side of a small UK practice that charges clients for time-based sessions. It keeps a client list, tracks the rate each client is charged and how that rate has changed over time, records individual sessions, gathers unbilled sessions into invoices, emails those invoices out as PDFs, and issues credit notes when an invoice needs adjusting downwards.
The application is single-tenant and password-protected: one practitioner signs in and manages everything. There is no client-facing login.
| Model | Purpose |
|---|---|
Client |
A person receiving sessions. Holds contact details, an active flag, and an optional paid_by reference to a Payee. |
Payee |
A third party that pays for one or more clients' sessions, for example an employer or a family member. Adds a mandatory organisation. |
Person |
A concern shared by Client and Payee providing name, email and address validation, an active scope, and address formatting. |
Fee |
One rate for one client over one date period, via from and to. The open-ended record with to: nil is the current rate. |
ClientSession |
A single billable session: a date, a number of units, a description, and the rate that applied at the time. Optionally linked to an Invoice. |
Invoice |
A billing document addressed to a client or their payee, gathering ClientSession records. Carries rich text, a generated PDF, and a status. |
CreditNote |
A negative adjustment raised against a specific invoice, with a mandatory reason. Reaches its client and payee by delegation through the invoice. |
Message |
Rich-text boilerplate to fold into invoice drafts, with an optional active date window. |
MessagesForClient |
Join table between messages and clients. A row with client_id: nil means the message applies to every client. |
User |
The signed-in practitioner. Managed by Clearance. |
Relationships worth knowing:
- A
Clienthas manyFeerecords, manyClientSessionrecords and manyInvoicerecords. - A
Clientoptionally belongs to aPayeethroughpaid_by. Unset means the client pays for themselves. - An
Invoicebelongs to aClientand optionally to aPayee, and has manyCreditNoterecords. - A
CreditNotebelongs to anInvoiceand has no direct client or payee columns of its own.
Money is stored as integer pence with a currency column, handled by the Money gem through
monetize. The default currency is GBP.
These are enforced in the models, so they hold regardless of which screen or console you come in through.
Each client's charge rate lives in a Fee row with a from and to date. Setting a new rate does
not overwrite the old one: Client#create_new_rate closes the current record off at the day before
the new rate starts and opens a fresh open-ended one. current_rate therefore always answers "what
do we charge today", while history stays intact for invoices raised in the past. Overlapping
periods are rejected by a validation, and every client must have at least one fee record.
A session captures the rate in force when it happened, so later rate changes never silently rewrite the value of work already recorded.
A session records units as a decimal, entered in steps of 0.5, rather than a start and end time.
Its value is simply unit_session_rate * units. New sessions default to 1 unit at the client's
current rate. A single unit is typically 50 mins, giving the practitioner 10 mins to prepare
for the next session.
Status runs created → sent → paid, and Invoice#status_change_ok? refuses anything else,
including reopening a paid invoice. While an invoice is still created it can be freely edited;
once it is sent or paid, any change to a field other than the status is rejected, rich text
included. Only a created invoice can be deleted.
The same immutability protects the sessions inside it: ClientSession blocks its own update and
destroy once its invoice has left created. While an invoice is still editable, changing a
session's units or rate recalculates the invoice total automatically.
A new invoice inherits its payee from the client's paid_by, unless a payee was explicitly
assigned. self_paid flips this: setting it true clears the payee so the invoice is billed to the
client directly, and the invoice form offers this as a radio choice whenever the client has a payee
on file. Invoice#self_paid is simply "has no payee".
Two after_initialize hooks populate the rich-text body of a new invoice:
- Client messages. Any
Messagecurrently inside its date window that applies to this client, or to all clients, is folded into the text. - Unpaid invoice reminders. Any earlier invoice for the client that is not yet paid produces a chase line. One outstanding invoice gets a single sentence; several produce a bulleted list.
Both are only a starting point. The text is editable before the invoice is created, which is the whole point of generating it into the draft rather than bolting it on at send time.
A credit note can be raised only against an invoice that has been sent or paid, so nothing is
credited before it was ever billed. Its amount is forced negative on validation, cannot be zero,
and cannot exceed the invoice it credits. A reason is required. The lifecycle is just
created → sent; once sent it is final and can be neither edited nor deleted.
Credit notes do not automatically adjust the parent invoice's balance. That reconciliation is treated as an accounting concern outside this system.
Client#deleteable? refuses deletion of an active client, of one with unpaid invoices, of one with
uninvoiced sessions, and of one with any invoice less than five years old. A before_destroy hook
enforces this and returns the reason, which the UI surfaces.
Payees are protected too, but only in PayeesController#destroy, which rejects deletion while any
client still references the payee. Unlike the client rules, that guard is not enforced at the model
level and so does not apply from the console.
Sending is a single action from the invoice or credit note page:
- The show page is rendered to HTML against a dedicated
pdflayout. FerrumPdfconverts that HTML to a PDF using headless Chrome.- The PDF is attached to the record via Active Storage, so the document that was sent stays retrievable and is not regenerated on later sends.
InvoiceMailerorCreditNoteMaileremails it to the client, with the PDF attached.- The record is marked
sent.
Organisation details on the document and bank details in the email body are read from encrypted credentials rather than hard-coded. See Credentials.
In development, mail opens in the browser through letter_opener instead of being delivered.
The application follows UK conventions throughout, and the tests assert on them:
- Dates display as
31 May 2025, viastrftime("%d %B %Y"). - Currency is pounds sterling, formatted through the Money gem.
- Postcodes are validated against a UK-format regular expression.
- Date entry fields are
date_fieldinputs, so the browser renders themdd/mm/yyyy. This depends on the browser's locale, which is why CI pins Chrome toen_GB.
| Area | Choice |
|---|---|
| Language | Ruby 4.0.6, pinned in .ruby-version |
| Framework | Rails 8.1.3.1, with config.load_defaults 8.1 |
| Database | SQLite in all environments |
| Money | money-rails |
| Auth | Clearance |
| Assets | Propshaft, with importmap-rails for JavaScript |
| Front end | Turbo and Stimulus, plus Pure.css and Material Symbols |
| Rich text | Action Text |
| Files | Active Storage, with image_processing for variants |
ferrum_pdf, driving headless Chrome |
|
| Background jobs, cache, cable | Solid Queue, Solid Cache, Solid Cable, all database-backed |
| Deployment | Docker image deployed with Kamal, fronted by Thruster |
| Tests | RSpec, FactoryBot, Faker, Capybara, Selenium, SimpleCov |
| Static analysis | Brakeman, plus RuboCop via rubocop-rails-omakase |
There is no separate Redis or Postgres to run: the queue, cache and cable adapters all sit in
SQLite, and SOLID_QUEUE_IN_PUMA runs the worker inside the web process in production.
The interface is deliberately server-rendered. Sixteen small Stimulus controllers handle the interactive parts, chiefly delete and send confirmation modals, the active/inactive filters, live rate lookup when picking a client, and showing the payee reference field only when a payee is selected. Styling comes from Pure.css with Material Symbols icons loaded from Google Fonts.
Requires Ruby 4.0.6 and a Chrome or Chromium binary on PATH for PDF generation and system tests.
bundle install
bin/rails db:setup # create and load schema
bin/rails serverdb/seeds.rb is still the generated stub, so db:setup leaves you with an empty database.
Then sign in at http://localhost:3000. The root path is the client index.
Sign-up is disabled, via config.allow_sign_up = false in config/initializers/clearance.rb, so
create the first user from the console:
bin/rails console
User.create!(email: "you@example.com", password: "a-strong-password")The application reads organisation and bank details from encrypted credentials, and views will raise without them. Two credential sets are in play:
| Environment | File | Key |
|---|---|---|
| development, production | config/credentials.yml.enc |
config/master.key |
| test | config/credentials/test.yml.enc |
config/credentials/test.key |
Both key files are gitignored. Edit them with
bin/rails credentials:edit and bin/rails credentials:edit --environment test. The expected
shape is:
org_details:
name: ...
address1: ...
address2: ...
town: ...
postcode: ...
email: ...
payment_details:
bank: ...
account_name: ...
account_number: ...
sort_code: ...bundle exec rspec # everything
bundle exec rspec spec/models # one directory
bundle exec rspec spec/path/to_spec.rb:42 # one example
bundle exec rspec --tag focus # focused examplesThe suite is around 440 examples across model, system, mailer and routing specs. System specs drive
a real browser through Selenium. They run headless by default; examples tagged js: true use a
visible Chrome instead, which is handy locally but needs a virtual display on a build server.
Database state is managed by DatabaseCleaner, truncating for browser-driven specs because the
application under test does not share the spec's connection. spec/rails_helper.rb signs a user in
before every system spec, so specs start from an authenticated session.
Two environment requirements are easy to trip over when running the suite outside a normal desktop:
LANGUAGE=en_GB. Date inputs take their format from Chrome's UI locale. Under a US-English Chrome the fields becomemm/dd/yyyy, and a date typed as13/08/2026is silently stored as 8 December instead of 13 August. The failure looks like a wrong-date assertion, not a parse error.- The test credentials key, as either
config/credentials/test.keyorRAILS_MASTER_KEY. Without it, views that readcredentials.org_detailsfail onnil.
bundle exec brakeman # security scan
bundle exec rubocop # lint
bundle exec rubocop -a # autocorrect.github/workflows/ci.yml runs four jobs on every pull request and every push to main:
| Job | Command |
|---|---|
test |
xvfb-run -a bundle exec rspec |
lint |
bin/rubocop -f github |
scan_ruby |
bin/brakeman --no-pager |
scan_js |
bin/importmap audit |
All four take their Ruby version from .ruby-version. The test job supplies LANGUAGE=en_GB and
RAILS_MASTER_KEY for the reasons above, and uploads screenshots from tmp/capybara when a system
spec fails. It deliberately does not install google-chrome-stable, because the runner image
already ships Chrome with a matching chromedriver and installing it introduces a version mismatch.
Deployment is a Docker image driven by Kamal. config/deploy.yml carries placeholder values for
the server address, registry and hostname, so it needs filling in before first use. The image
expects RAILS_MASTER_KEY in the environment and runs Solid Queue inside Puma.
bin/kamal setup # first deploy
bin/kamal deploy # subsequent deploys/up returns 200 once the application has booted, for load balancers and uptime checks.
app/ models, controllers, views, mailers, Stimulus controllers
config/ routes, environments, credentials, Kamal deploy config
db/ migrations and schema
spec/ RSpec specs, factories and support
DesignDocs/ feature specifications and implementation notes
TODO.md outstanding ideas
CLAUDE.md guidance for Claude Code
DesignDocs/ holds two kinds of file: short requirement statements written before a feature was
built, such as Billing.md, CreateMessages.md and SeparatePayeeFromClient.md, and longer
implementation notes written afterwards.
Treat them as a historical record rather than as a description of the code. Two significant designs in there were implemented and then reversed, and the documents were not all updated:
Billing.mdandCREDIT_NOTES_GUIDE.mddescribe aBillingsuperclass using single table inheritance, abillingstable, aBillingsControllerand anappliedcredit note status. That was built, then unwound.SEPARATION_COMPLETION.mdandAPPLIED_STATUS_REMOVAL.mdrecord the reversal. TodayInvoiceandCreditNoteare separate models on separate tables, the invoice index shows both, and credit notes stop atsent.SeparatePayeeFromClient.mdproposes a singlePersontable using single table inheritance with atypecolumn. What was actually built is aPersonconcern shared by two independentclientsandpayeestables.
The schema in db/schema.rb and the models themselves are the authority.