Bílahótel.is
A car storage business that stopped taking bookings in 2022 and wanted to reopen in 2026. The old system worked; the question was whether to restart it or replace it.

The old system worked.
That was the problem.
The first version was a Laravel and Vue app I built in 2020 and maintained through two seasons — coupons, invoicing, saved vehicles, national ID validation, SMS with Icelandic characters, PDF confirmations, a luggage storage product with physical locker codes. It closed for bookings in April 2022 and sat dormant for four years.
Restarting it meant inheriting all of that. Most of those features existed because someone asked once, not because the business needed them, and every one of them was a thing to maintain in a codebase four years out of date. The honest read was that the 2020 app had taught me what the business actually required, and that was a much smaller product than what I had built.
Three decisions that
shaped everything else.
The rebuild kept the business logic and threw away almost everything else.
Put the catalogue in the CMS
There is no service table in the database. Tyre changes, oil changes, cleaning, storage tiers, prices, and the date ranges when something cannot be booked all live in Prismic as five custom types. Staff close a week for tyre work or change a price without me, and without a deploy. The database holds transactions and nothing else.
Snapshot the price into the booking
Selected services land in the reservation row as a JSON column with their prices baked in. It gives up SQL aggregation by service, which is a real cost. What it buys is that a reservation is an immutable receipt — editing a price in the CMS next month cannot retroactively change what someone was charged last month.
Three tables, no relations
Reservations, admins, and one-time codes. No user accounts, no coupons, no saved vehicles, no kennitala. Customers book without registering, staff sign in with an emailed six-digit code, and the whole schema fits on one screen. Everything the 2020 version had that this does not is a thing I decided the business would not miss.
Three steps, placeable by an editor
Dates, vehicle details, services — mounted as a Prismic slice variation, so the booking form is something staff can put on a page rather than something I deploy. Date range plus vehicle size drives a live catalogue fetch, because both determine what can even be offered. Storage is never chosen by the customer: the day count picks the tier automatically and injects it as a line item.

A price change is an edit, not a deploy
Every service is a Prismic document across five repeatable types, so the public price list and the booking form read the same records — a number cannot be current in one and stale in the other. The pricing shape is fields too, not code: an oil service with a max price renders as a range, a per-litre flag appends the unit, storage prices by the day inside a min-max band. Staff change a price, add a service, or untick bookable to pull something off the form while it stays on the price list.

A bank gateway with
no SDK and two hashes.
Payment runs through Borgun SecurePay, which has no npm package. The integration is a form POST built at runtime in the DOM and submitted cross-origin, then a server-to-server callback that has to be verified before a booking is marked paid. Both directions are signed, and the two signatures are not the same construction — the outgoing one covers merchant, both callback URLs, order, amount and currency; the incoming one covers only order, amount and currency.
Getting that wrong in the forgiving direction means accepting a forged confirmation. The callback verifies with a timing-safe comparison, wrapped in a try/catch because the comparison throws outright on a length mismatch, and only marks the reservation paid when both the step and the status match. It answers with the literal XML acknowledgement the gateway requires, which is not documented anywhere except in the gateway's own examples.
Most of the debugging was not cryptographic. The gateway returned its status in inconsistent casing between environments, which cost two commits and a production booking that paid successfully and was recorded as unpaid. Amounts are rounded to integers throughout because the Icelandic króna has no subunit, and item descriptions are truncated to eighty characters because the gateway silently rejects longer ones.