Skip to content
Erick Schaedler

In production

Trips do Geo

A booking platform for guided expeditions with limited seats per departure, built to get concurrent reservations and payment webhooks right.

View live Opens in a new tab

The problem

A departure with 20 spots can sell out in minutes, and two people can try to reserve the last spot within the same second — the booking flow has to get that right every time, not most of the time.

What I built

A Next.js storefront and a NestJS API where every operation that touches capacity — booking, group cancellation, refund — runs inside one database transaction that locks the departure, and the booking when one already exists, before changing anything.

Trips do Geo homepage showing the next departure, remaining days and price per person.
Trips do Geo homepage on mobile, showing the next departure card.

Technical decisions

Seats aren’t tracked with a counter column — they’re counted on demand, inside a transaction that locks the departure row before checking capacity and before creating the booking. Two people booking the last spot at the same instant both hit that same lock; one gets the seat, the other gets a clear “sold out” instead of a race that overbooks the trip. I have an integration test that proves this directly: it fires two concurrent booking requests at a departure with 3 seats, 2 people each, and asserts exactly one succeeds.

A reservation isn’t counted as occupying a seat only once it’s paid — it counts from the moment it’s created, for 30 minutes, whether or not payment has arrived yet. That hold is what stops the gap between “reserved” and “paid” from becoming a second way to overbook: someone mid-checkout still holds their seat, but if they abandon it, the seat frees itself automatically once the hold expires.

A booking can hold up to 20 people. When one person in an already-paid group drops out, canceling them is a soft cancel, not a delete — it frees their seat immediately, but the rest of the group keeps their spots and their payment untouched. Their share of a refund is computed from their own price, not divided evenly, and it goes into a separate pending-refund total rather than the booking’s own refund flag — refunds are never issued automatically here; a human confirms them.

The Mercado Pago webhook locks the target booking row before touching it, verifies the request’s signature, and treats a payment ID it has already seen as a no-op. A second payment landing on an already-paid booking isn’t silently dropped or double-counted — it’s queued for a manual refund and an email goes out, not to the customer, but to the trip’s guide, with the customer’s WhatsApp number right there in the message so the guide can reach out directly. Refunding money automatically through an API is exactly the kind of decision I don’t want a webhook making on its own — a human confirms it first.

The two race conditions that actually matter both come down to a payment notification arriving after something else already changed. If it arrives after a hold expired and the seat went to someone else, the booking is marked expired and flagged for refund instead of being confirmed into an overbook. If it arrives while the trip itself is being cancelled, the cancellation wins and the whole booking is flagged for refund — the order these two checks run in is deliberate, and it’s the difference between refunding one booking and refunding an entire departure by mistake.

Outcome

Running in production at tripsdogeo.com.br, with an integration test that fires two simultaneous bookings for the last spot and confirms only one is ever accepted.

Stack

  • NestJS
  • Prisma
  • PostgreSQL
  • Next.js
  • TypeScript
  • Mercado Pago