All work

Brasov Private Tours

Online booking and card payments for a Brașov company that runs private day trips and airport transfers. Built so a car is never sold twice.

Type
Client project
Role
Design, build, deploy
When
September 2026
Built with
Next.js 16, React 19, Postgres, Prisma 7, Stripe, Resend, Vercel
Status
Pre-launch
Links
Home page of Brasov Private Tours: a trip finder over a photo of Făgăraș fortress.

The job

Brasov Private Tours is a company in Brașov that runs private day trips and airport transfers with its own car and driver. Guests need to pick a trip, a day, a pickup time and a car, see the real price, and pay either the driver or online. Each car does one trip at a time, so the same car can never be sold twice for the same hours.

The company needed a Romanian admin to confirm requests, block days, change prices and refund card payments. The site had to work in English, Romanian, Italian and Spanish, keep the old site’s links working, and meet Romanian consumer and data protection rules.

What I built

A Next.js booking site with a Postgres database, Stripe payments and a Romanian admin. Every “Book” button opens the same booking sheet as a drawer, and one button at the bottom always names the next missing step, such as “Choose a date”.

  • Availability rules as pure, tested functions: one trip per car, a 30-minute buffer, pickups 07:00 to 22:00, one hour’s notice, up to 12 months ahead.
  • Seasonal trips, and Bucharest airport transfers that take the car for the whole day, because flight delays make the rest of the day unreliable.
  • Pay the driver in cash or by card, or pay online with Stripe Checkout, with automatic refunds on cancellation.
  • An admin to confirm, cancel with a refund, block days, edit prices and retire cars without breaking old bookings.
  • Four languages with hreflang, a sitemap, structured data and 301 redirects from the old URLs.
  • A nightly job that deletes guest data on the privacy policy’s schedule.
  • 60 CC-licensed photos from Wikimedia Commons, self-hosted as WebP with credits.
The booking sheet open as a drawer over the home page, with a calendar of free days.
One booking sheet for every trip. The button at the bottom names the next step.

The hard parts

Two guests, one car, the same evening

If two people press “Book” for the same car and time in the same second, both screens show the slot as free. Only one of them can have it.

Every booking is written in a Postgres transaction that first takes advisory locks: one for the date, and one each for the guest’s email, phone and hashed IP. Inside the locks, the code reads the day again, checks the slot again and runs the rate limits. An earlier version counted bookings outside the transaction, and parallel requests all passed the same count. The locks are taken in sorted order, so two transactions can never wait on each other forever.

// src/lib/bookings.ts
async function lockBookingKeys(tx: Prisma.TransactionClient, keys: string[]) {
  for (const key of [...new Set(keys)].sort()) {
    await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key}))`;
  }
}

The same transaction reads the current price. If the free car now costs more than the total the guest saw, the booking stops instead of charging more.

A payment that arrives after the car is gone

A card booking holds its car for about 42 minutes: a 32-minute Stripe Checkout session plus 10 minutes of grace. Stripe’s webhook can arrive late, or twice.

The function that marks a booking paid is safe to run repeatedly, and both the webhook and the success page call it. Under the date lock, it checks whether another booking took the car after the hold ended. If one did, the booking is cancelled, the money goes back automatically, and the guest and the company are told. The refund carries a Stripe idempotency key, so a retry never refunds twice. While money is still owed, the webhook answers with an error, so Stripe keeps retrying.

The email that goes out exactly once

The webhook and the success page often arrive at the same moment. Without care, the guest gets two “you’re booked” emails, or none if the server stops halfway.

The booking row carries a marker such as “confirmed: pending”, written in the same transaction as the status change. The sender claims it with a conditional update that swaps “pending” for “sending”. Only the call that changed the row sends.

// src/lib/payments.ts
// Claim the send: only the call that swaps the marker sends, the other one leaves it alone.
const { count } = await prisma.booking.updateMany({ where: { id: current.id, emailError: pendingMark(owed) }, data: { emailError: sendingMark(owed) } });
if (count === 1) {
  const kind = current.conflict ? "conflict" : "confirmed";
  await sendBookingEmails(current, await tourTitle(current.tourSlug, current.locale), kind);
}

Anything left unsent shows in the admin with a resend button.

Tests that cannot touch real bookings

The integration and end-to-end tests create and delete bookings, so they must never reach the live database. A code review found one setup in which the end-to-end tests could reuse a development server connected to it.

Now a guard refuses any database host other than localhost or a named test host. The Playwright config runs it before any server starts, then starts its own dev server with the checked URL, and reuses a running one only when asked. In CI, both suites run against a Postgres 16 service container.

Results

  • 79 unit, 24 integration and 16 end-to-end tests pass, and CI is green on main (26 September 2026).
  • Live site check: PASS, 0 errors on 9 pages (25 September 2026).
  • The header was checked in 4 languages at 29 widths each (26 September 2026).
  • Photos drawn larger than their files: 16 of 99 before a fix, 0 of 104 after (26 September 2026).
  • Web fonts cut from 481 KB to about 150 KB (26 September 2026).

What I would do next

  • Launch on the company’s own domain, with live card payments on its own Stripe account.
  • Redirect the remaining old URLs that still end in a 404.
  • Watch the first real bookings and tune the availability rules with the company.
Next case studyThis site