All workΕΛ

Case study · International Rentals

In development

Fleet operations, off paper

A Greek rent-a-car company runs roughly 100 cars through reps stationed at hotel desks. Availability, prices, contracts and cash all live on paper and in phone calls. This is the operations platform replacing that, end to end.

Why there is no link

There is nothing to open. This is an internal tool behind a login, holding a real company's fleet, customers and money, so the build itself is what this page shows: the screens, the rules underneath them and the reasoning that got there.

  • ~100

    cars, held to the day rather than to the hour

  • 6-10

    reps at hotel desks, plus one manager

  • 2

    languages, 1,082 strings each, kept in step by a test

  • 47

    test files, 31 of them against a real Postgres

The problem

Everything worked, and nothing was written down twice

The company works the way a lot of island rental businesses work. A guest asks at a hotel desk, the rep phones the office, someone looks at a sheet of paper, and a car is either free or it is not. It holds together because the same people have done it for years.

What it cannot do is answer a question twice the same way. Two reps can promise the same car within the same minute. A price quoted at one desk can differ from the price at the next. A guest who returns a car early frees a car that nobody knows is free. And every one of those failures costs money at exactly the moment the season is busiest.

So the brief was not really software. It was: replace the paper without breaking the day, and never be the reason a car gets promised twice.

Two users

One codebase for two people who must not see the same thing

A rep works one-handed, on a phone, at a hotel desk, in sunlight, with a guest standing there. The manager works on a desktop and needs the whole company at once. Same app, same deploy, two completely different jobs.

The commercial rule is blunt: a rep never sees an aggregate. Not a count, not a sum, not an average. If a number on a rep's screen would let company revenue be worked out, it does not go on the screen. The single exception is their own cash in hand today, because they are the person carrying it.

That rule is not enforced by hiding buttons. Authorisation lives in the database: row-level security is on for every table, with policies a rep's own session is tested against. A rep who edits an API call by hand reaches exactly the same wall, because the wall is not in the route handler.

The rep's phone app beside the manager's desktop day sheet
The rep's day and the manager's day sheet, from one deploy. Every plate, guest and hotel shown here is invented.Open full size

Correctness

Two reps race for the last car, and the database refuses

Double-booking is the failure that matters, so it is not left to application logic that a later change could undo. Rentals and admin blocks live in one table, and one Postgres exclusion constraint covers both. Two overlapping holds on the same car cannot exist, because the write itself is refused.

A failed insert therefore means something true: the car is genuinely taken. The rep is told that in plain language instead of being handed a car that is gone.

The other half of correctness is the day rule. A day here runs morning to night, so a Monday pickup returned on Wednesday is three days and the car is held through all of Wednesday. Every date calculation in the codebase is inclusive of both ends. Two bookings may touch and not overlap: one ending on the 15th and the next starting on the 16th is legal.

Reps never query the bookings table for availability at all. They call one function that returns car ids and occupied dates and nothing else, so learning that a car is taken cannot also leak who took it, from which hotel, for how much, or why.

The guarantee, in one constraint
EXCLUDE USING gist (
  car_id WITH =,
  daterange(start_date, end_date, '[]') WITH &&
) WHERE (status IN ('booked','out','blocked'))
The exclusion constraint, the Postgres error it raises, and the message the rep is shown
A database error nobody should ever read, translated into one sentence a rep can act on.Open full size

Pricing

The price list is never shipped to a rep's phone

Pricing runs on the server and only on the server. The rep's device asks for a quote and displays the number it gets back. It cannot recompute that number, and it never holds the table the number came from.

The rules are the client's own, so the app follows them rather than improving on them. Totals are typed in per season, per category and per day count, and they already contain the first-day premium, so the app adds nothing on top. Past seven days it falls to a per-extra-day rate. Baby seats and additional drivers are free and stay free.

Two details keep the money honest over time. Money is integer cents everywhere, from the database through to the PDF, so no float ever touches a total. And the pricing period is stored on the booking itself, so editing next season's price list cannot quietly rewrite what a guest already agreed to pay.

If a pickup date falls in no defined period, quoting fails loudly. It does not guess, and it does not borrow a neighbouring period's numbers.

A new booking on the rep's phone beside the server-side quote rule
The rep sees one number for the booking in front of them, with the days it was built from.Open full size

The paper

Everything the clipboard did, in the order it happens

A pickup is a sequence, one thing per screen, and it survives the app being closed halfway through. Licence photographs, then the eligibility check, then fuel, then existing damage on a car diagram, then the agreement and the signature, then payment.

Driving licences are read server-side by vision OCR, and the fields come back editable. The extracted text is treated as untrusted input from a stranger's card, parsed into a strict schema with anything outside it discarded, and rate limited per user. A failed scan costs nothing: typing the fields in always works.

The eligibility check is a hard stop. Age against the category minimum, licence held at least a year, expiry beyond the return date. The screen names the rule that failed and offers the rep nothing but a request for an admin override, and the override is recorded when it is granted.

The agreement is generated as a bilingual PDF with both drivers, the fuel level, the damage diagram and the guest's signature captured on the glass. It is built deterministically on the server, with no headless browser to keep alive in production.

The pickup sequence, a failed eligibility check, and the bilingual rental agreement
A guest who is too young for the category cannot be given the car, and the rep is not asked to decide that.Open full size

Two languages

Greek and English from the first commit

The reps are Greek. Half the guests are not. Retrofitting a second language into a working app is a rewrite, so there has never been a hard-coded user-facing string in this codebase.

Both languages carry 1,082 strings, and a test fails the build if one of them grows a key the other does not have. Language is a per-user setting rather than a browser guess, because a rep's phone is theirs and a guest's expectations are not.

The rental agreement is bilingual on the same page rather than in two documents, which is what a guest signing at a desk actually needs, and what a Greek company issuing it needs too.

The room it is used in

A phone, one hand, sunlight, and a guest waiting

This is an operational tool used twenty times a day, not a showcase. Motion exists to explain a state change and then get out of the way. Legibility beats elegance, and large targets beat density.

WCAG 2.1 AA is the floor, not the ambition: every foreground and background pair in the design tokens clears it, and a unit test computes those ratios from the token values rather than trusting a comment that may have gone stale. The camera and signature steps have non-visual paths, because a flow that only works by sight is a flow that excludes people.

It ships to Android as a Trusted Web Activity rather than a second native codebase, which keeps the Play listing, the camera and push notifications while letting a mid-season fix deploy in minutes instead of waiting in a review queue.

What holds it up

The tests are the part that lets it be changed

47 test files, 31 of which run against a real Postgres with the same policies that ship. No service-role shortcuts and no route handlers in the way: a rep session doing its worst, checked against what it is allowed to see.

The two engines were written before the screens that use them, because a wrong availability answer or a wrong price is not a visual bug and would not have been caught by looking.

45 migrations, every write audit-logged, and a permanent read-only audit trail the manager can filter by actor, entity and date. When something is disputed in August, the answer exists.

Where it stands

  1. Phase 0 to 1Built

    Foundations, the full schema, row-level security on every table, and the availability and pricing engines with their tests written first.

  2. Phase 2 to 3Built

    Booking, availability and the manager's screens. Then pickup and return end to end, incidents, cash in hand and hand-over.

  3. Phase 4Built

    The bilingual contract, the on-screen signature and licence OCR with a manual fallback that always works.

  4. October 2026Ahead

    Test build at one hotel, running in parallel with paper for a fortnight. Paper is the safety net, and the comparison is the test.

  5. 1 March 2027Ahead

    Season launch, with the winter spent on what the pilot exposed: reports, push notifications and the Play Store listing.

Built with

App

  • Next.js App Router
  • TypeScript
  • Tailwind
  • next-intl

Data and auth

  • Postgres
  • Row Level Security
  • Zod

Documents and vision

  • @react-pdf/renderer
  • Claude vision OCR
  • Canvas signature

Delivery

  • Railway
  • Android TWA
  • Web Push
  • Vitest

Building something this size?

Tell me what your business needs to run on and I'll come back within 24 hours with a clear, no-obligation quote.

Request a quote