02

A travel agency: its own catalogue, booking in three steps

Client product · three languages, CMS, seat inventory, booking, deploy and handover

VeTravel sells package tours, excursions and coach hire from Bosnia and Herzegovina. The new site runs in Bosnian, English and German: a catalogue with filters and a search that takes a whole sentence, tour pages with a day-by-day programme and a table of departures with live seat counts, booking in three steps, and request pages for coach hire, flight tickets and trips built to order. The agency runs all of it from the admin panel, in its own language.

How it worked before

The old site was a shop window: the catalogue was empty, and the only way to buy a tour was to write through a form, a Viber community or a WhatsApp group.

What made it hard

A coach has a fixed number of seats, and two people can reach for the last one in the same second. The owner writes in Bosnian, has no developer, and will not type every tour three times. Browsers ship incomplete Bosnian locale data, so a date or a price can render one way on the server and another on the phone. And card payment is not a technical question: the usual gateway does not serve Bosnia, so the booking path has to work without a card.

Engineering decisions

  1. 01

    The database refuses to oversell

    Seats are claimed by one conditional UPDATE: of two requests for the last seat, the second matches zero rows and gets a plain “sold out”. Underneath sits a CHECK constraint, seats taken ≤ capacity, so even code that bypasses this path — or a query typed by hand — is refused.

  2. 02

    A held seat comes back without waiting for a scheduler

    Step one holds the seats for 30 minutes before any details are typed. The hosting plan allows a cron once a day, which would quietly turn 30 minutes into 24 hours, so every new booking first reclaims the lapsed holds on its own date. The cron is only a backstop.

  3. 03

    Bosnian is the source, English and German fill themselves

    Validation applies only to the source language, so a tour can be published in Bosnian alone. On publish, empty languages are translated by the model and marked “needs review”; one hand-written paragraph and that language is never touched again. A reply that loses a paragraph or a line of the “included” list is rejected whole.

  4. 04

    A sentence becomes filters, deterministic first

    “more u avgustu do 1000 KM autobusom” fills four filters and shows what was understood. A parser handles month stems across Bosnian declension, price ceilings, transport and destinations in three languages with no network call. The model sees only what is left, answers through a four-field tool, and its output is validated back into the shapes the URL accepts.

  5. 05

    Limits on what a stranger can make the site do

    Admin passwords of at least 12 characters (the package default was three), a lockout after five failed attempts, rate limits on seat holds, forms and paid model calls. Counters live in Postgres, because memory on serverless limits nothing, and an IP is kept only as a salted hash that is gone within the hour.

What else is in there

A comparison of two or three tours that shows only what differs, a printable trip plan, a price for the whole family or group, a coach-hire estimate where the money is computed in code and the model supplies only the road distance, a destination map with no tile provider, reviews and video reviews that cannot be published without a consent tick, a passenger manifest as CSV, gap-free document numbers from a Postgres sequence, and a card-payment branch kept dark behind a flag, its idempotent callback proven by tests.

Bugs only production showed

Chrome claims to support Bosnian and prints a date as “2026 M10 17” — replaced by an explicit formatter for all three languages. Photographs went out through a serverless function, and a 146 KB image took 10–22 seconds; they now come straight from the CDN. Untranslated tours led to a 404 in English and German until one slug resolver took over. After the move to the custom domain the admin login failed silently between www and the apex; one host now serves the app, enforced in code.

Where it stands

Live on the agency's own domain in three languages, with a written handover of what is done and what waits on the owner. Card payment is built behind a flag and waits on a gateway the agency can contract; the model features switch on with one environment key, and everything works without it.

Traveller
picks a date and party size
Step 1: seats held
one conditional UPDATE, 30 minutes
Steps 2–3
names, contacts, confirmation
Booking reference
traveller and agency notified
Sold out
said plainly, nothing written
Hold lapsed
seats go back to the date
Agency admin
bookings, manifest, document number
CHECK in Postgres
taken never exceeds capacity
One booking. Seats are taken before anything is typed, so nobody fills in a form only to hear “sold out”. The claim is a single statement, and the constraint beneath it holds even if that statement is ever bypassed.

Got something like this?

Get in touch