Build FareHarbor
KINDAback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - 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 - accountfree 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/bookings | Bookings, capacity and passenger details, including any stated requirements. |
HOLD_MINUTES | required | 10 | How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. |
BOOKING_CUTOFF_MINUTES | required | 120 | How long before departure bookings close. Per activity in practice, with this as the default. |
STRIPE_SECRET_KEYsecret | required | sk_test_... | Test keys until deposits, balances and refunds are all proven. |
STRIPE_WEBHOOK_SECRETsecret | required | whsec_... | Verifies incoming events. An unverified endpoint is an open endpoint. |
DEFAULT_TIMEZONE | required | Europe/Lisbon | The default activity zone. Each activity carries its own, which is what actually matters. |
The build, in order
Departures that mean the right time
A departure time is unambiguous for a customer anywhere in the world.
The local departure time is the fact. The instant is derived from it.
Files
src/departures.tsTest both. A season generated naively will be an hour wrong for a week.
Files
src/schedule.tssrc/schedule.test.tsThe customer needs to know when to turn up where the tour is.
Files
src/display.ts
done when · tick each as it passeswatch 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.
Capacity that cannot be oversold
Two simultaneous bookings for the last two seats never both succeed when only one seat exists.
Against the departure row. Let the database serialise the contention.
Files
src/booking.tsFifty parallel requests for ten seats. Sequential tests prove nothing here.
Files
tests/concurrency.test.tsA boat's capacity changes with weather and crew. The reason matters later.
Files
src/capacity.ts
done when · tick each as it passeswatch 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.
Holding a seat while someone pays
A customer entering card details does not lose the seat, and does not keep it forever.
Held is unavailable. Otherwise two people pay for one seat.
Files
src/holds.tsAbandoned checkouts are the normal case and never come back to cancel.
Files
src/expiry.tsIt happens. Creating an oversold booking is not the answer.
Files
src/holds.ts
done when · tick each as it passeswatch 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.
Money, deposits and getting it back
Payments work, deposits are collected, and cancellations refund correctly.
Card details on your server is a different project entirely.
Files
src/payments.tsThe quoted figure is the refunded figure. Test that they are the same.
Files
src/refund.tssrc/refund.test.tsThe policy governs customer cancellation. It does not govern yours.
Files
src/cancel.ts
done when · tick each as it passeswatch 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.
The manifest, and what the guide needsproduct builder
The person running the tour has an accurate list, including offline.
Downloaded before departure. There is no signal at the dock.
Files
src/manifest.tsAnd reconcile without duplicates when a connection returns.
Files
src/checkin.tsNobody can be added to a boat that has left.
Files
src/cutoff.ts
done when · tick each as it passeswatch out- A manifest that requires a connection is useless at a departure point with no signal, which is most departure points for this kind of business. Make it available offline before departure and let check-in queue locally, since the guide needs it at exactly the moment the network does not exist.
That is the whole plan for FareHarbor. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Distribution through resellers and marketplaces.
- Dynamic pricing and yield management.
- Equipment and staff scheduling.
- Multi-currency settlement and cross-border tax.
- OTA and reseller distribution: live availability pushed to Viator, GetYourGuide, Expedia and dozens of local agents, plus the commission accounting behind it
- Someone else owning payment risk: chargebacks, disputes, refund flows, payout timing, PCI scope and multi-currency
- 24/7 human support during a season where a broken booking page costs real departures that day
- The messy operational features you only miss in August: resource and guide assignment, gear allocation, multi-day and multi-activity itineraries, gift cards, promo codes, waivers, group and private bookings
- Mobile check-in that works on a beach with one bar of signal, and the reporting your accountant already knows how to read
- Waitlists, which turn a sold-out departure into revenue when someone cancels
- Weather-driven cancellation with templated notification, since it is the common case
- Private bookings of a whole departure, once capacity handling is trustworthy
Need the files? The project pack on the verdict page hands your agent the whole brief · more travel.