Build FareHarbor

KINDAback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A booking system whose entire difficulty is that capacity is finite and shared: two people booking the last two seats at the same instant, a boat whose capacity depends on the weather, and a customer in another country reading a departure time that must mean the local time where the tour actually leaves.

estimated effort multi-daythe files for this build are in the project pack

RuntimeTypeScript with Fastify and PostgreSQLCapacityReserved against the departure row inside the booking transactionTimeDepartures stored as an instant plus the activity's own zonePaymentsHosted checkout with deposits and scheduled balancesHoldsShort expiring holds during checkout, released automatically

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the server, the expiry worker and the payment webhooks.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree locally

    Why Capacity correctness depends on real transactions and row-level contention, which is exactly what this is for.

    Get it Docker or your package manager. open ↗

    Verify Two concurrent transactions updating one row serialise correctly

  3. free to test; per-transaction in production

    Why Deposits, scheduled balances and refunds all behave differently from a single charge, and only a real provider shows you how.

    Get it Sign up, use test keys, and configure a webhook endpoint with its signing secret. open ↗

    Verify A deposit, a later balance and a partial refund all complete in test mode

  4. installfree

    Why The capacity guarantee is the point of the build, and sequential tests cannot demonstrate it.

    Get it Any load tool that can fire fifty simultaneous requests at one endpoint.

    Verify Fifty requests arrive close enough together to contend for the same row

  5. decidefree

    Why A policy written in prose gets implemented three times slightly differently, and the customer sees whichever version the code path used.

    Get it Express it as bands: hours before departure and refund percentage. Decide what happens when you cancel, and what happens for weather.

    Verify The policy is written as a table of bands with an explicit operator-cancellation rule

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- activities(id, name, timezone, default_capacity, duration_minutes, cutoff_minutes, cancellation_policy_id)
- departures(id, activity_id, starts_at_utc, local_date, capacity, capacity_reason, sold, held, status) · status is scheduled, cancelled or full
- holds(id, departure_id, quantity, expires_at, session_id) · counted against capacity while alive
- bookings(id, departure_id, quantity, customer_id, total_minor, currency, status, created_at)
- passengers(booking_id, ordinal, name, requirements) · because a headcount is not the same as knowing who is coming
- payments(id, booking_id, kind, amount_minor, provider_ref, at) · kind is deposit, balance or refund
- capacity_changes(departure_id, before, after, reason, by, at) · a boat's capacity changes and the reason matters later

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/bookingsBookings, capacity and passenger details, including any stated requirements.
HOLD_MINUTESrequired10How long a checkout holds a seat. Long enough to pay, short enough not to block a departure.
BOOKING_CUTOFF_MINUTESrequired120How long before departure bookings close. Per activity in practice, with this as the default.
STRIPE_SECRET_KEYsecretrequiredsk_test_...Test keys until deposits, balances and refunds are all proven.
STRIPE_WEBHOOK_SECRETsecretrequiredwhsec_...Verifies incoming events. An unverified endpoint is an open endpoint.
DEFAULT_TIMEZONErequiredEurope/LisbonThe default activity zone. Each activity carries its own, which is what actually matters.

The build, in order

  1. Departures that mean the right time

    A departure time is unambiguous for a customer anywhere in the world.

    1. The local departure time is the fact. The instant is derived from it.

      Files src/departures.ts

    2. Test both. A season generated naively will be an hour wrong for a week.

      Files src/schedule.tssrc/schedule.test.ts

    3. The customer needs to know when to turn up where the tour is.

      Files src/display.ts

    done when · tick each as it passes
    watch out
    • Storing a departure in the operator's local time and converting for display breaks the moment the schedule crosses a daylight transition, producing a tour that appears to depart an hour early for a week. The departure time is a local time at the activity's location, and that is the authoritative fact, so store the zone with it rather than assuming one.
  2. Capacity that cannot be oversold

    Two simultaneous bookings for the last two seats never both succeed when only one seat exists.

    1. Against the departure row. Let the database serialise the contention.

      Files src/booking.ts

    2. Fifty parallel requests for ten seats. Sequential tests prove nothing here.

      Files tests/concurrency.test.ts

    3. A boat's capacity changes with weather and crew. The reason matters later.

      Files src/capacity.ts

    done when · tick each as it passes
    watch out
    • Checking availability and then inserting a booking is a race with a window of milliseconds, and it will oversell the moment a tour becomes popular, which is exactly when overselling is most damaging. Take the capacity within the transaction that creates the booking, so the database serialises the contention for you.
  3. Holding a seat while someone pays

    A customer entering card details does not lose the seat, and does not keep it forever.

    1. Held is unavailable. Otherwise two people pay for one seat.

      Files src/holds.ts

    2. Abandoned checkouts are the normal case and never come back to cancel.

      Files src/expiry.ts

    3. It happens. Creating an oversold booking is not the answer.

      Files src/holds.ts

    done when · tick each as it passes
    watch out
    • A hold released only when the customer cancels leaves abandoned checkouts holding seats indefinitely, and a popular departure fills with holds that will never become bookings. Expire them on a timer that runs regardless of whether anyone comes back, and count held seats as unavailable while the hold is alive.
  4. Money, deposits and getting it back

    Payments work, deposits are collected, and cancellations refund correctly.

    1. Card details on your server is a different project entirely.

      Files src/payments.ts

    2. The quoted figure is the refunded figure. Test that they are the same.

      Files src/refund.tssrc/refund.test.ts

    3. The policy governs customer cancellation. It does not govern yours.

      Files src/cancel.ts

    done when · tick each as it passes
    watch out
    • Computing a refund in the interface and again in the payment path lets the two disagree, and the customer is shown one figure and refunded another, which is the dispute you least want. Compute it once, show that exact figure, and refund that figure.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more travel.