Vibecode FareHarbor
track this build4 phases, 12 steps, beginner friendly0%The part everyone thinks they are paying for, a calendar with capacity, a checkout, and a manifest the guides can read, is genuinely a weekend build with Stripe doing the hard money parts. The part that is actually load-bearing is everything after that: pushing live availability to Viator, GetYourGuide, Expedia and Booking.com, reconciling reseller commissions, handling refunds and chargebacks on other people's money, and a phone line that answers at 6am when the 7am kayak trip is double-booked. A self-hosted build replaces direct-website bookings cleanly and replaces channel distribution not at all. If most of your volume is direct and you can eat the ops burden, the maths gets interesting fast. If OTAs feed you, you are rebuilding a channel manager, which is not a weekend.
You are building a lean indie version of FareHarbor. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # FareHarbor · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | capacity is a transactional problem and needs a database that can hold a line | | Capacity | Reserved against the departure row inside the booking transaction | checking availability then inserting is a race that oversells | | Time | Departures stored as an instant plus the activity's own zone | the departure time means the local time where the tour leaves, always | | Payments | Hosted checkout with deposits and scheduled balances | card details on your server is a compliance project rather than a booking system | | Holds | Short expiring holds during checkout, released automatically | a customer entering card details should not lose the seat, and should not hold it forever | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the expiry worker and the payment webhooks. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: Two concurrent transactions updating one row serialise correctly - [ ] **A payment provider account in test mode** · 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. - Verify: A deposit, a later balance and a partial refund all complete in test mode - [ ] **A way to issue genuinely concurrent requests** · free - 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 - [ ] **Your cancellation policy as data** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for FareHarbor, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · FareHarbor The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me booking and availability for tours and activities, where a seat can only be sold once to replace FareHarbor. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · capacity is a transactional problem and needs a database that can hold a line - Capacity: Reserved against the departure row inside the booking transaction · checking availability then inserting is a race that oversells - Time: Departures stored as an instant plus the activity's own zone · the departure time means the local time where the tour leaves, always - Payments: Hosted checkout with deposits and scheduled balances · card details on your server is a compliance project rather than a booking system - Holds: Short expiring holds during checkout, released automatically · a customer entering card details should not lose the seat, and should not hold it forever ### Data model (create this before Phase 1) - 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 ### Phase 1 · Departures that mean the right time Build: Activities with schedules generating departures, stored as an instant plus the activity's own zone. Display in the activity's local time by default with the customer's zone available. Handle daylight transitions in the activity's location. Trap: 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. Done when: A departure shows the same local time to customers in three different zones. A recurring schedule crossing a daylight transition produces correct departures on both sides, tested on both transitions. A customer's own zone is shown as a secondary hint rather than replacing the local time. Generating a season of departures does not produce duplicates or gaps. Do not build yet: No capacity, holds or booking. ### Phase 2 · Capacity that cannot be oversold Build: Reserve capacity inside the booking transaction against the departure row, so concurrent attempts serialise. Test with genuine concurrent requests rather than sequential ones. Return a clear result when capacity ran out mid-checkout. Trap: 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. Done when: Fifty concurrent requests for ten remaining seats produce exactly ten bookings, asserted in a test with real concurrency. A booking that fails on capacity returns a clear reason rather than a generic error. Capacity changes are recorded with a reason. A departure at capacity is marked full rather than requiring a count on every read. Do not build yet: No holds, payments or cancellation. ### Phase 3 · Holding a seat while someone pays Build: Create a short expiring hold when checkout begins, counted against capacity. Release it automatically on expiry. Convert it to a booking on payment confirmation. Handle the customer abandoning checkout and returning later. Trap: 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. Done when: A hold reserves capacity and expires automatically without any user action, tested by abandoning a checkout. An expired hold's capacity becomes available again. A payment arriving after the hold expired is handled explicitly rather than creating an oversold booking. Held seats are unavailable to other customers while the hold is alive. Do not build yet: No payments or cancellation yet. ### Phase 4 · Money, deposits and getting it back Build: Hosted checkout taking a deposit or full payment. Schedule balance collection before departure. Implement the cancellation policy as data with the refund computed and shown before confirming. Handle a departure the operator cancels. Trap: 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. Done when: A deposit collects and a balance is scheduled. The refund shown before cancelling is the amount actually refunded, asserted by a test comparing the quoted and executed figures. An operator cancelling a departure refunds every booking in full regardless of policy. A failed balance payment is retried and surfaced rather than silently cancelling the booking. Do not build yet: No manifests or reporting yet. ### Phase 5 · The manifest, and what the guide needs Build: A departure manifest with passengers, requirements and payment status, available offline and printable. Cutoff enforcement so bookings close before departure. A check-in that works without a connection and reconciles later. Trap: 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. Done when: A manifest is available offline before departure with passengers, requirements and payment status. Check-in works with no connection and reconciles without duplicates. Bookings close at the cutoff. A last-minute capacity reduction is reflected in the manifest and affected customers are identified. Do not build yet: Nothing after this. ### Out of scope (and why) - Distribution through resellers and marketplaces, which is most of what the hosted platforms provide and is a whole integration business. - Dynamic pricing and yield management, which is a pricing product rather than a booking one. - Equipment and staff scheduling, which is a separate resource allocation problem. - Multi-currency settlement and tax handling across jurisdictions, which is genuinely specialist work. ### README must contain - That a departure time means the local time at the activity's location and is stored with its zone - That capacity is taken inside the booking transaction, and why check-then-insert oversells - That holds expire on a timer rather than on a user action - That the refund quoted before cancelling is the refund executed - That the manifest works offline because departure points usually have no signal ===== AGENTS.md ===== # Agent instructions · FareHarbor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Reserved against the departure row inside the booking transaction, Departures stored as an instant plus the activity's own zone, Hosted checkout with deposits and scheduled balances, Short expiring holds during checkout, released automatically. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · FareHarbor 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Departures that mean the right time A departure time is unambiguous for a customer anywhere in the world. ### Steps 1. Store the instant and the activity's zone The local departure time is the fact. The instant is derived from it. Files: `src/departures.ts` 2. Generate schedules across transitions Test both. A season generated naively will be an hour wrong for a week. Files: `src/schedule.ts`, `src/schedule.test.ts` 3. Show local time first The customer needs to know when to turn up where the tour is. Files: `src/display.ts` ### Done when - [ ] One departure shows identically across three zones - [ ] Both daylight transitions generate correctly - [ ] The customer's zone is a hint, not a replacement - [ ] A season generates without duplicates or gaps ### 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. ## Phase 2 · Capacity that cannot be oversold Two simultaneous bookings for the last two seats never both succeed when only one seat exists. ### Steps 1. Reserve inside the booking transaction Against the departure row. Let the database serialise the contention. Files: `src/booking.ts` 2. Test with real concurrency Fifty parallel requests for ten seats. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Record capacity changes with reasons A boat's capacity changes with weather and crew. The reason matters later. Files: `src/capacity.ts` ### Done when - [ ] Fifty concurrent requests for ten seats yield ten bookings - [ ] A capacity failure returns a clear reason - [ ] Capacity changes record a reason - [ ] A full departure is marked rather than counted each time ### 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. ## Phase 3 · Holding a seat while someone pays A customer entering card details does not lose the seat, and does not keep it forever. ### Steps 1. Hold on checkout start, counted against capacity Held is unavailable. Otherwise two people pay for one seat. Files: `src/holds.ts` 2. Expire on a timer, not on an action Abandoned checkouts are the normal case and never come back to cancel. Files: `src/expiry.ts` 3. Handle payment after expiry explicitly It happens. Creating an oversold booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] A hold expires without user action - [ ] Expired capacity becomes available - [ ] Payment after expiry is handled explicitly - [ ] Held seats are unavailable to others ### 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. ## Phase 4 · Money, deposits and getting it back Payments work, deposits are collected, and cancellations refund correctly. ### Steps 1. Use hosted checkout with deposits Card details on your server is a different project entirely. Files: `src/payments.ts` 2. Compute the refund once The quoted figure is the refunded figure. Test that they are the same. Files: `src/refund.ts`, `src/refund.test.ts` 3. Operator cancellation refunds fully The policy governs customer cancellation. It does not govern yours. Files: `src/cancel.ts` ### Done when - [ ] A deposit collects and a balance is scheduled - [ ] The quoted refund equals the executed refund - [ ] Operator cancellation refunds every booking fully - [ ] A failed balance payment is retried and surfaced ### 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. ## Not in this build - Distribution through resellers and marketplaces. - Dynamic pricing and yield management. - Equipment and staff scheduling. - Multi-currency settlement and cross-border tax. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Bookings, capacity and passenger details, including any stated requirements. DATABASE_URL=postgres://localhost:5432/bookings # Required. How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. HOLD_MINUTES=10 # Required. How long before departure bookings close. Per activity in practice, with this as the default. BOOKING_CUTOFF_MINUTES=120 # Required · secret. Test keys until deposits, balances and refunds are all proven. STRIPE_SECRET_KEY=sk_test_... # Required · secret. Verifies incoming events. An unverified endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The default activity zone. Each activity carries its own, which is what actually matters. DEFAULT_TIMEZONE=Europe/Lisbon
You are building a lean indie version of FareHarbor. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # FareHarbor · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | capacity is a transactional problem and needs a database that can hold a line | | Capacity | Reserved against the departure row inside the booking transaction | checking availability then inserting is a race that oversells | | Time | Departures stored as an instant plus the activity's own zone | the departure time means the local time where the tour leaves, always | | Payments | Hosted checkout with deposits and scheduled balances | card details on your server is a compliance project rather than a booking system | | Holds | Short expiring holds during checkout, released automatically | a customer entering card details should not lose the seat, and should not hold it forever | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the expiry worker and the payment webhooks. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: Two concurrent transactions updating one row serialise correctly - [ ] **A payment provider account in test mode** · 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. - Verify: A deposit, a later balance and a partial refund all complete in test mode - [ ] **A way to issue genuinely concurrent requests** · free - 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 - [ ] **Your cancellation policy as data** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for FareHarbor, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · FareHarbor The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me booking and availability for tours and activities, where a seat can only be sold once to replace FareHarbor. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · capacity is a transactional problem and needs a database that can hold a line - Capacity: Reserved against the departure row inside the booking transaction · checking availability then inserting is a race that oversells - Time: Departures stored as an instant plus the activity's own zone · the departure time means the local time where the tour leaves, always - Payments: Hosted checkout with deposits and scheduled balances · card details on your server is a compliance project rather than a booking system - Holds: Short expiring holds during checkout, released automatically · a customer entering card details should not lose the seat, and should not hold it forever ### Data model (create this before Phase 1) - 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 ### Phase 1 · Departures that mean the right time Build: Activities with schedules generating departures, stored as an instant plus the activity's own zone. Display in the activity's local time by default with the customer's zone available. Handle daylight transitions in the activity's location. Trap: 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. Done when: A departure shows the same local time to customers in three different zones. A recurring schedule crossing a daylight transition produces correct departures on both sides, tested on both transitions. A customer's own zone is shown as a secondary hint rather than replacing the local time. Generating a season of departures does not produce duplicates or gaps. Do not build yet: No capacity, holds or booking. ### Phase 2 · Capacity that cannot be oversold Build: Reserve capacity inside the booking transaction against the departure row, so concurrent attempts serialise. Test with genuine concurrent requests rather than sequential ones. Return a clear result when capacity ran out mid-checkout. Trap: 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. Done when: Fifty concurrent requests for ten remaining seats produce exactly ten bookings, asserted in a test with real concurrency. A booking that fails on capacity returns a clear reason rather than a generic error. Capacity changes are recorded with a reason. A departure at capacity is marked full rather than requiring a count on every read. Do not build yet: No holds, payments or cancellation. ### Phase 3 · Holding a seat while someone pays Build: Create a short expiring hold when checkout begins, counted against capacity. Release it automatically on expiry. Convert it to a booking on payment confirmation. Handle the customer abandoning checkout and returning later. Trap: 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. Done when: A hold reserves capacity and expires automatically without any user action, tested by abandoning a checkout. An expired hold's capacity becomes available again. A payment arriving after the hold expired is handled explicitly rather than creating an oversold booking. Held seats are unavailable to other customers while the hold is alive. Do not build yet: No payments or cancellation yet. ### Phase 4 · Money, deposits and getting it back Build: Hosted checkout taking a deposit or full payment. Schedule balance collection before departure. Implement the cancellation policy as data with the refund computed and shown before confirming. Handle a departure the operator cancels. Trap: 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. Done when: A deposit collects and a balance is scheduled. The refund shown before cancelling is the amount actually refunded, asserted by a test comparing the quoted and executed figures. An operator cancelling a departure refunds every booking in full regardless of policy. A failed balance payment is retried and surfaced rather than silently cancelling the booking. Do not build yet: No manifests or reporting yet. ### Phase 5 · The manifest, and what the guide needs Build: A departure manifest with passengers, requirements and payment status, available offline and printable. Cutoff enforcement so bookings close before departure. A check-in that works without a connection and reconciles later. Trap: 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. Done when: A manifest is available offline before departure with passengers, requirements and payment status. Check-in works with no connection and reconciles without duplicates. Bookings close at the cutoff. A last-minute capacity reduction is reflected in the manifest and affected customers are identified. Do not build yet: Nothing after this. ### Out of scope (and why) - Distribution through resellers and marketplaces, which is most of what the hosted platforms provide and is a whole integration business. - Dynamic pricing and yield management, which is a pricing product rather than a booking one. - Equipment and staff scheduling, which is a separate resource allocation problem. - Multi-currency settlement and tax handling across jurisdictions, which is genuinely specialist work. ### README must contain - That a departure time means the local time at the activity's location and is stored with its zone - That capacity is taken inside the booking transaction, and why check-then-insert oversells - That holds expire on a timer rather than on a user action - That the refund quoted before cancelling is the refund executed - That the manifest works offline because departure points usually have no signal ===== AGENTS.md ===== # Agent instructions · FareHarbor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Reserved against the departure row inside the booking transaction, Departures stored as an instant plus the activity's own zone, Hosted checkout with deposits and scheduled balances, Short expiring holds during checkout, released automatically. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · FareHarbor 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Departures that mean the right time A departure time is unambiguous for a customer anywhere in the world. ### Steps 1. Store the instant and the activity's zone The local departure time is the fact. The instant is derived from it. Files: `src/departures.ts` 2. Generate schedules across transitions Test both. A season generated naively will be an hour wrong for a week. Files: `src/schedule.ts`, `src/schedule.test.ts` 3. Show local time first The customer needs to know when to turn up where the tour is. Files: `src/display.ts` ### Done when - [ ] One departure shows identically across three zones - [ ] Both daylight transitions generate correctly - [ ] The customer's zone is a hint, not a replacement - [ ] A season generates without duplicates or gaps ### 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. ## Phase 2 · Capacity that cannot be oversold Two simultaneous bookings for the last two seats never both succeed when only one seat exists. ### Steps 1. Reserve inside the booking transaction Against the departure row. Let the database serialise the contention. Files: `src/booking.ts` 2. Test with real concurrency Fifty parallel requests for ten seats. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Record capacity changes with reasons A boat's capacity changes with weather and crew. The reason matters later. Files: `src/capacity.ts` ### Done when - [ ] Fifty concurrent requests for ten seats yield ten bookings - [ ] A capacity failure returns a clear reason - [ ] Capacity changes record a reason - [ ] A full departure is marked rather than counted each time ### 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. ## Phase 3 · Holding a seat while someone pays A customer entering card details does not lose the seat, and does not keep it forever. ### Steps 1. Hold on checkout start, counted against capacity Held is unavailable. Otherwise two people pay for one seat. Files: `src/holds.ts` 2. Expire on a timer, not on an action Abandoned checkouts are the normal case and never come back to cancel. Files: `src/expiry.ts` 3. Handle payment after expiry explicitly It happens. Creating an oversold booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] A hold expires without user action - [ ] Expired capacity becomes available - [ ] Payment after expiry is handled explicitly - [ ] Held seats are unavailable to others ### 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. ## Phase 4 · Money, deposits and getting it back Payments work, deposits are collected, and cancellations refund correctly. ### Steps 1. Use hosted checkout with deposits Card details on your server is a different project entirely. Files: `src/payments.ts` 2. Compute the refund once The quoted figure is the refunded figure. Test that they are the same. Files: `src/refund.ts`, `src/refund.test.ts` 3. Operator cancellation refunds fully The policy governs customer cancellation. It does not govern yours. Files: `src/cancel.ts` ### Done when - [ ] A deposit collects and a balance is scheduled - [ ] The quoted refund equals the executed refund - [ ] Operator cancellation refunds every booking fully - [ ] A failed balance payment is retried and surfaced ### 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. ## Not in this build - Distribution through resellers and marketplaces. - Dynamic pricing and yield management. - Equipment and staff scheduling. - Multi-currency settlement and cross-border tax. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Bookings, capacity and passenger details, including any stated requirements. DATABASE_URL=postgres://localhost:5432/bookings # Required. How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. HOLD_MINUTES=10 # Required. How long before departure bookings close. Per activity in practice, with this as the default. BOOKING_CUTOFF_MINUTES=120 # Required · secret. Test keys until deposits, balances and refunds are all proven. STRIPE_SECRET_KEY=sk_test_... # Required · secret. Verifies incoming events. An unverified endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The default activity zone. Each activity carries its own, which is what actually matters. DEFAULT_TIMEZONE=Europe/Lisbon
You are building a production product version of FareHarbor. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # FareHarbor · product brief ## Problem The part everyone thinks they are paying for, a calendar with capacity, a checkout, and a manifest the guides can read, is genuinely a weekend build with Stripe doing the hard money parts. The part that is actually load-bearing is everything after that: pushing live availability to Viator, GetYourGuide, Expedia and Booking.com, reconciling reseller commissions, handling refunds and chargebacks on other people's money, and a phone line that answers at 6am when the 7am kayak trip is double-booked. A self-hosted build replaces direct-website bookings cleanly and replaces channel distribution not at all. If most of your volume is direct and you can eat the ops burden, the maths gets interesting fast. If OTAs feed you, you are rebuilding a channel manager, which is not a weekend. ## Product outcome A booking system where the last seat is sold exactly once, a departure time means the same thing to everyone, and the guide has an accurate list at a dock with no signal. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account (test mode is fine to build against) - a domain and any Node host, plus a Postgres or SQLite volume - transactional email provider for confirmations - clear rules for your own cancellation, deposit and capacity logic before you start ## Explicit non-goals for v1 - 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 ## Success criteria - Fifty concurrent requests for ten seats are confirmed to yield ten bookings - Both daylight transitions are confirmed to generate correct departures - A hold is confirmed to expire without user action - The quoted refund is confirmed equal to the executed refund - The manifest is confirmed available offline ===== BRIEF.md ===== # Build brief · FareHarbor The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me booking and availability for tours and activities, where a seat can only be sold once to replace FareHarbor. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · capacity is a transactional problem and needs a database that can hold a line - Capacity: Reserved against the departure row inside the booking transaction · checking availability then inserting is a race that oversells - Time: Departures stored as an instant plus the activity's own zone · the departure time means the local time where the tour leaves, always - Payments: Hosted checkout with deposits and scheduled balances · card details on your server is a compliance project rather than a booking system - Holds: Short expiring holds during checkout, released automatically · a customer entering card details should not lose the seat, and should not hold it forever ### Data model (create this before Phase 1) - 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 ### Phase 1 · Departures that mean the right time Build: Activities with schedules generating departures, stored as an instant plus the activity's own zone. Display in the activity's local time by default with the customer's zone available. Handle daylight transitions in the activity's location. Trap: 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. Done when: A departure shows the same local time to customers in three different zones. A recurring schedule crossing a daylight transition produces correct departures on both sides, tested on both transitions. A customer's own zone is shown as a secondary hint rather than replacing the local time. Generating a season of departures does not produce duplicates or gaps. Do not build yet: No capacity, holds or booking. ### Phase 2 · Capacity that cannot be oversold Build: Reserve capacity inside the booking transaction against the departure row, so concurrent attempts serialise. Test with genuine concurrent requests rather than sequential ones. Return a clear result when capacity ran out mid-checkout. Trap: 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. Done when: Fifty concurrent requests for ten remaining seats produce exactly ten bookings, asserted in a test with real concurrency. A booking that fails on capacity returns a clear reason rather than a generic error. Capacity changes are recorded with a reason. A departure at capacity is marked full rather than requiring a count on every read. Do not build yet: No holds, payments or cancellation. ### Phase 3 · Holding a seat while someone pays Build: Create a short expiring hold when checkout begins, counted against capacity. Release it automatically on expiry. Convert it to a booking on payment confirmation. Handle the customer abandoning checkout and returning later. Trap: 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. Done when: A hold reserves capacity and expires automatically without any user action, tested by abandoning a checkout. An expired hold's capacity becomes available again. A payment arriving after the hold expired is handled explicitly rather than creating an oversold booking. Held seats are unavailable to other customers while the hold is alive. Do not build yet: No payments or cancellation yet. ### Phase 4 · Money, deposits and getting it back Build: Hosted checkout taking a deposit or full payment. Schedule balance collection before departure. Implement the cancellation policy as data with the refund computed and shown before confirming. Handle a departure the operator cancels. Trap: 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. Done when: A deposit collects and a balance is scheduled. The refund shown before cancelling is the amount actually refunded, asserted by a test comparing the quoted and executed figures. An operator cancelling a departure refunds every booking in full regardless of policy. A failed balance payment is retried and surfaced rather than silently cancelling the booking. Do not build yet: No manifests or reporting yet. ### Phase 5 · The manifest, and what the guide needs Build: A departure manifest with passengers, requirements and payment status, available offline and printable. Cutoff enforcement so bookings close before departure. A check-in that works without a connection and reconciles later. Trap: 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. Done when: A manifest is available offline before departure with passengers, requirements and payment status. Check-in works with no connection and reconciles without duplicates. Bookings close at the cutoff. A last-minute capacity reduction is reflected in the manifest and affected customers are identified. Do not build yet: Nothing after this. ### Out of scope (and why) - Distribution through resellers and marketplaces, which is most of what the hosted platforms provide and is a whole integration business. - Dynamic pricing and yield management, which is a pricing product rather than a booking one. - Equipment and staff scheduling, which is a separate resource allocation problem. - Multi-currency settlement and tax handling across jurisdictions, which is genuinely specialist work. ### README must contain - That a departure time means the local time at the activity's location and is stored with its zone - That capacity is taken inside the booking transaction, and why check-then-insert oversells - That holds expire on a timer rather than on a user action - That the refund quoted before cancelling is the refund executed - That the manifest works offline because departure points usually have no signal ===== ARCHITECTURE.md ===== # Architecture · FareHarbor ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | capacity is a transactional problem and needs a database that can hold a line | | Capacity | Reserved against the departure row inside the booking transaction | checking availability then inserting is a race that oversells | | Time | Departures stored as an instant plus the activity's own zone | the departure time means the local time where the tour leaves, always | | Payments | Hosted checkout with deposits and scheduled balances | card details on your server is a compliance project rather than a booking system | | Holds | Short expiring holds during checkout, released automatically | a customer entering card details should not lose the seat, and should not hold it forever | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Departures | Schedules, instants and the activity's zone. | Any generator, provided the local departure time is the authoritative fact. | | Capacity | Reservation inside the booking transaction. | Not swappable for check-then-insert, which oversells under exactly the conditions that matter. | | Holds | Expiring reservations during checkout. | Any implementation, provided expiry is on a timer rather than an action. | | Payments | Deposits, balances and refunds. | Any provider with hosted checkout, provided the refund is computed once. | | Manifest | Offline departure lists and check-in. | Any implementation that works with no connection at the departure point. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Bookings, capacity and passenger details, including any stated requirements. - `HOLD_MINUTES` · required · How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. - `BOOKING_CUTOFF_MINUTES` · required · How long before departure bookings close. Per activity in practice, with this as the default. - `STRIPE_SECRET_KEY` · required, secret · Test keys until deposits, balances and refunds are all proven. - `STRIPE_WEBHOOK_SECRET` · required, secret · Verifies incoming events. An unverified endpoint is an open endpoint. - `DEFAULT_TIMEZONE` · required · The default activity zone. Each activity carries its own, which is what actually matters. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · FareHarbor product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Reserved against the departure row inside the booking transaction, Departures stored as an instant plus the activity's own zone, Hosted checkout with deposits and scheduled balances, Short expiring holds during checkout, released automatically. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · FareHarbor Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Departures that mean the right time A departure time is unambiguous for a customer anywhere in the world. ### Steps 1. Store the instant and the activity's zone The local departure time is the fact. The instant is derived from it. Files: `src/departures.ts` 2. Generate schedules across transitions Test both. A season generated naively will be an hour wrong for a week. Files: `src/schedule.ts`, `src/schedule.test.ts` 3. Show local time first The customer needs to know when to turn up where the tour is. Files: `src/display.ts` ### Done when - [ ] One departure shows identically across three zones - [ ] Both daylight transitions generate correctly - [ ] The customer's zone is a hint, not a replacement - [ ] A season generates without duplicates or gaps ### 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. ## M2 · Capacity that cannot be oversold Two simultaneous bookings for the last two seats never both succeed when only one seat exists. ### Steps 1. Reserve inside the booking transaction Against the departure row. Let the database serialise the contention. Files: `src/booking.ts` 2. Test with real concurrency Fifty parallel requests for ten seats. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Record capacity changes with reasons A boat's capacity changes with weather and crew. The reason matters later. Files: `src/capacity.ts` ### Done when - [ ] Fifty concurrent requests for ten seats yield ten bookings - [ ] A capacity failure returns a clear reason - [ ] Capacity changes record a reason - [ ] A full departure is marked rather than counted each time ### 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. ## M3 · Holding a seat while someone pays A customer entering card details does not lose the seat, and does not keep it forever. ### Steps 1. Hold on checkout start, counted against capacity Held is unavailable. Otherwise two people pay for one seat. Files: `src/holds.ts` 2. Expire on a timer, not on an action Abandoned checkouts are the normal case and never come back to cancel. Files: `src/expiry.ts` 3. Handle payment after expiry explicitly It happens. Creating an oversold booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] A hold expires without user action - [ ] Expired capacity becomes available - [ ] Payment after expiry is handled explicitly - [ ] Held seats are unavailable to others ### 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. ## M4 · Money, deposits and getting it back Payments work, deposits are collected, and cancellations refund correctly. ### Steps 1. Use hosted checkout with deposits Card details on your server is a different project entirely. Files: `src/payments.ts` 2. Compute the refund once The quoted figure is the refunded figure. Test that they are the same. Files: `src/refund.ts`, `src/refund.test.ts` 3. Operator cancellation refunds fully The policy governs customer cancellation. It does not govern yours. Files: `src/cancel.ts` ### Done when - [ ] A deposit collects and a balance is scheduled - [ ] The quoted refund equals the executed refund - [ ] Operator cancellation refunds every booking fully - [ ] A failed balance payment is retried and surfaced ### 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. ## M5 · The manifest, and what the guide needs (production only) The person running the tour has an accurate list, including offline. ### Steps 1. Make the manifest available offline Downloaded before departure. There is no signal at the dock. Files: `src/manifest.ts` 2. Queue check-in locally And reconcile without duplicates when a connection returns. Files: `src/checkin.ts` 3. Enforce the booking cutoff Nobody can be added to a boat that has left. Files: `src/cutoff.ts` ### Done when - [ ] The manifest is available offline with all details - [ ] Check-in works offline and reconciles cleanly - [ ] Bookings close at the cutoff - [ ] A capacity reduction identifies affected customers ### Watch 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. ===== OPERATIONS.md ===== # Operations · FareHarbor ## Backup Bookings are money owed and services promised. Nightly backups with a tested restore, and consider that passenger requirements may include health information, which raises the sensitivity of the whole table. ## Restore Restore and confirm capacity counts match the bookings, since a restore that loses recent bookings leaves seats appearing available that customers have already paid for. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Oversell attempts and hold expiry rate. A rising rate of holds expiring without conversion usually means the checkout is failing rather than that customers changed their minds. ## Incident checklist An oversold departure is the incident that reaches a customer at the dock. It means capacity was taken outside the transaction, and the concurrency test is the control that should have caught it. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Fifty concurrent requests for ten seats are confirmed to yield ten bookings - [ ] Both daylight transitions are confirmed to generate correct departures - [ ] A hold is confirmed to expire without user action - [ ] The quoted refund is confirmed equal to the executed refund - [ ] The manifest is confirmed available offline ## Launch constraint Do not market omitted FareHarbor capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Bookings, capacity and passenger details, including any stated requirements. DATABASE_URL=postgres://localhost:5432/bookings # Required. How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. HOLD_MINUTES=10 # Required. How long before departure bookings close. Per activity in practice, with this as the default. BOOKING_CUTOFF_MINUTES=120 # Required · secret. Test keys until deposits, balances and refunds are all proven. STRIPE_SECRET_KEY=sk_test_... # Required · secret. Verifies incoming events. An unverified endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The default activity zone. Each activity carries its own, which is what actually matters. DEFAULT_TIMEZONE=Europe/Lisbon
# FareHarbor · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | capacity is a transactional problem and needs a database that can hold a line | | Capacity | Reserved against the departure row inside the booking transaction | checking availability then inserting is a race that oversells | | Time | Departures stored as an instant plus the activity's own zone | the departure time means the local time where the tour leaves, always | | Payments | Hosted checkout with deposits and scheduled balances | card details on your server is a compliance project rather than a booking system | | Holds | Short expiring holds during checkout, released automatically | a customer entering card details should not lose the seat, and should not hold it forever | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the expiry worker and the payment webhooks. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: Two concurrent transactions updating one row serialise correctly - [ ] **A payment provider account in test mode** · 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. - Verify: A deposit, a later balance and a partial refund all complete in test mode - [ ] **A way to issue genuinely concurrent requests** · free - 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 - [ ] **Your cancellation policy as data** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for FareHarbor, and the README should say so rather than pretend.
# Build brief · FareHarbor The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me booking and availability for tours and activities, where a seat can only be sold once to replace FareHarbor. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · capacity is a transactional problem and needs a database that can hold a line - Capacity: Reserved against the departure row inside the booking transaction · checking availability then inserting is a race that oversells - Time: Departures stored as an instant plus the activity's own zone · the departure time means the local time where the tour leaves, always - Payments: Hosted checkout with deposits and scheduled balances · card details on your server is a compliance project rather than a booking system - Holds: Short expiring holds during checkout, released automatically · a customer entering card details should not lose the seat, and should not hold it forever ### Data model (create this before Phase 1) - 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 ### Phase 1 · Departures that mean the right time Build: Activities with schedules generating departures, stored as an instant plus the activity's own zone. Display in the activity's local time by default with the customer's zone available. Handle daylight transitions in the activity's location. Trap: 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. Done when: A departure shows the same local time to customers in three different zones. A recurring schedule crossing a daylight transition produces correct departures on both sides, tested on both transitions. A customer's own zone is shown as a secondary hint rather than replacing the local time. Generating a season of departures does not produce duplicates or gaps. Do not build yet: No capacity, holds or booking. ### Phase 2 · Capacity that cannot be oversold Build: Reserve capacity inside the booking transaction against the departure row, so concurrent attempts serialise. Test with genuine concurrent requests rather than sequential ones. Return a clear result when capacity ran out mid-checkout. Trap: 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. Done when: Fifty concurrent requests for ten remaining seats produce exactly ten bookings, asserted in a test with real concurrency. A booking that fails on capacity returns a clear reason rather than a generic error. Capacity changes are recorded with a reason. A departure at capacity is marked full rather than requiring a count on every read. Do not build yet: No holds, payments or cancellation. ### Phase 3 · Holding a seat while someone pays Build: Create a short expiring hold when checkout begins, counted against capacity. Release it automatically on expiry. Convert it to a booking on payment confirmation. Handle the customer abandoning checkout and returning later. Trap: 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. Done when: A hold reserves capacity and expires automatically without any user action, tested by abandoning a checkout. An expired hold's capacity becomes available again. A payment arriving after the hold expired is handled explicitly rather than creating an oversold booking. Held seats are unavailable to other customers while the hold is alive. Do not build yet: No payments or cancellation yet. ### Phase 4 · Money, deposits and getting it back Build: Hosted checkout taking a deposit or full payment. Schedule balance collection before departure. Implement the cancellation policy as data with the refund computed and shown before confirming. Handle a departure the operator cancels. Trap: 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. Done when: A deposit collects and a balance is scheduled. The refund shown before cancelling is the amount actually refunded, asserted by a test comparing the quoted and executed figures. An operator cancelling a departure refunds every booking in full regardless of policy. A failed balance payment is retried and surfaced rather than silently cancelling the booking. Do not build yet: No manifests or reporting yet. ### Phase 5 · The manifest, and what the guide needs Build: A departure manifest with passengers, requirements and payment status, available offline and printable. Cutoff enforcement so bookings close before departure. A check-in that works without a connection and reconciles later. Trap: 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. Done when: A manifest is available offline before departure with passengers, requirements and payment status. Check-in works with no connection and reconciles without duplicates. Bookings close at the cutoff. A last-minute capacity reduction is reflected in the manifest and affected customers are identified. Do not build yet: Nothing after this. ### Out of scope (and why) - Distribution through resellers and marketplaces, which is most of what the hosted platforms provide and is a whole integration business. - Dynamic pricing and yield management, which is a pricing product rather than a booking one. - Equipment and staff scheduling, which is a separate resource allocation problem. - Multi-currency settlement and tax handling across jurisdictions, which is genuinely specialist work. ### README must contain - That a departure time means the local time at the activity's location and is stored with its zone - That capacity is taken inside the booking transaction, and why check-then-insert oversells - That holds expire on a timer rather than on a user action - That the refund quoted before cancelling is the refund executed - That the manifest works offline because departure points usually have no signal
# Agent instructions · FareHarbor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Reserved against the departure row inside the booking transaction, Departures stored as an instant plus the activity's own zone, Hosted checkout with deposits and scheduled balances, Short expiring holds during checkout, released automatically. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · FareHarbor 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Departures that mean the right time A departure time is unambiguous for a customer anywhere in the world. ### Steps 1. Store the instant and the activity's zone The local departure time is the fact. The instant is derived from it. Files: `src/departures.ts` 2. Generate schedules across transitions Test both. A season generated naively will be an hour wrong for a week. Files: `src/schedule.ts`, `src/schedule.test.ts` 3. Show local time first The customer needs to know when to turn up where the tour is. Files: `src/display.ts` ### Done when - [ ] One departure shows identically across three zones - [ ] Both daylight transitions generate correctly - [ ] The customer's zone is a hint, not a replacement - [ ] A season generates without duplicates or gaps ### 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. ## Phase 2 · Capacity that cannot be oversold Two simultaneous bookings for the last two seats never both succeed when only one seat exists. ### Steps 1. Reserve inside the booking transaction Against the departure row. Let the database serialise the contention. Files: `src/booking.ts` 2. Test with real concurrency Fifty parallel requests for ten seats. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Record capacity changes with reasons A boat's capacity changes with weather and crew. The reason matters later. Files: `src/capacity.ts` ### Done when - [ ] Fifty concurrent requests for ten seats yield ten bookings - [ ] A capacity failure returns a clear reason - [ ] Capacity changes record a reason - [ ] A full departure is marked rather than counted each time ### 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. ## Phase 3 · Holding a seat while someone pays A customer entering card details does not lose the seat, and does not keep it forever. ### Steps 1. Hold on checkout start, counted against capacity Held is unavailable. Otherwise two people pay for one seat. Files: `src/holds.ts` 2. Expire on a timer, not on an action Abandoned checkouts are the normal case and never come back to cancel. Files: `src/expiry.ts` 3. Handle payment after expiry explicitly It happens. Creating an oversold booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] A hold expires without user action - [ ] Expired capacity becomes available - [ ] Payment after expiry is handled explicitly - [ ] Held seats are unavailable to others ### 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. ## Phase 4 · Money, deposits and getting it back Payments work, deposits are collected, and cancellations refund correctly. ### Steps 1. Use hosted checkout with deposits Card details on your server is a different project entirely. Files: `src/payments.ts` 2. Compute the refund once The quoted figure is the refunded figure. Test that they are the same. Files: `src/refund.ts`, `src/refund.test.ts` 3. Operator cancellation refunds fully The policy governs customer cancellation. It does not govern yours. Files: `src/cancel.ts` ### Done when - [ ] A deposit collects and a balance is scheduled - [ ] The quoted refund equals the executed refund - [ ] Operator cancellation refunds every booking fully - [ ] A failed balance payment is retried and surfaced ### 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. ## Not in this build - Distribution through resellers and marketplaces. - Dynamic pricing and yield management. - Equipment and staff scheduling. - Multi-currency settlement and cross-border tax. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Bookings, capacity and passenger details, including any stated requirements. DATABASE_URL=postgres://localhost:5432/bookings # Required. How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. HOLD_MINUTES=10 # Required. How long before departure bookings close. Per activity in practice, with this as the default. BOOKING_CUTOFF_MINUTES=120 # Required · secret. Test keys until deposits, balances and refunds are all proven. STRIPE_SECRET_KEY=sk_test_... # Required · secret. Verifies incoming events. An unverified endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The default activity zone. Each activity carries its own, which is what actually matters. DEFAULT_TIMEZONE=Europe/Lisbon
# FareHarbor · product brief ## Problem The part everyone thinks they are paying for, a calendar with capacity, a checkout, and a manifest the guides can read, is genuinely a weekend build with Stripe doing the hard money parts. The part that is actually load-bearing is everything after that: pushing live availability to Viator, GetYourGuide, Expedia and Booking.com, reconciling reseller commissions, handling refunds and chargebacks on other people's money, and a phone line that answers at 6am when the 7am kayak trip is double-booked. A self-hosted build replaces direct-website bookings cleanly and replaces channel distribution not at all. If most of your volume is direct and you can eat the ops burden, the maths gets interesting fast. If OTAs feed you, you are rebuilding a channel manager, which is not a weekend. ## Product outcome A booking system where the last seat is sold exactly once, a departure time means the same thing to everyone, and the guide has an accurate list at a dock with no signal. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account (test mode is fine to build against) - a domain and any Node host, plus a Postgres or SQLite volume - transactional email provider for confirmations - clear rules for your own cancellation, deposit and capacity logic before you start ## Explicit non-goals for v1 - 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 ## Success criteria - Fifty concurrent requests for ten seats are confirmed to yield ten bookings - Both daylight transitions are confirmed to generate correct departures - A hold is confirmed to expire without user action - The quoted refund is confirmed equal to the executed refund - The manifest is confirmed available offline
# Build brief · FareHarbor The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me booking and availability for tours and activities, where a seat can only be sold once to replace FareHarbor. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · capacity is a transactional problem and needs a database that can hold a line - Capacity: Reserved against the departure row inside the booking transaction · checking availability then inserting is a race that oversells - Time: Departures stored as an instant plus the activity's own zone · the departure time means the local time where the tour leaves, always - Payments: Hosted checkout with deposits and scheduled balances · card details on your server is a compliance project rather than a booking system - Holds: Short expiring holds during checkout, released automatically · a customer entering card details should not lose the seat, and should not hold it forever ### Data model (create this before Phase 1) - 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 ### Phase 1 · Departures that mean the right time Build: Activities with schedules generating departures, stored as an instant plus the activity's own zone. Display in the activity's local time by default with the customer's zone available. Handle daylight transitions in the activity's location. Trap: 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. Done when: A departure shows the same local time to customers in three different zones. A recurring schedule crossing a daylight transition produces correct departures on both sides, tested on both transitions. A customer's own zone is shown as a secondary hint rather than replacing the local time. Generating a season of departures does not produce duplicates or gaps. Do not build yet: No capacity, holds or booking. ### Phase 2 · Capacity that cannot be oversold Build: Reserve capacity inside the booking transaction against the departure row, so concurrent attempts serialise. Test with genuine concurrent requests rather than sequential ones. Return a clear result when capacity ran out mid-checkout. Trap: 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. Done when: Fifty concurrent requests for ten remaining seats produce exactly ten bookings, asserted in a test with real concurrency. A booking that fails on capacity returns a clear reason rather than a generic error. Capacity changes are recorded with a reason. A departure at capacity is marked full rather than requiring a count on every read. Do not build yet: No holds, payments or cancellation. ### Phase 3 · Holding a seat while someone pays Build: Create a short expiring hold when checkout begins, counted against capacity. Release it automatically on expiry. Convert it to a booking on payment confirmation. Handle the customer abandoning checkout and returning later. Trap: 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. Done when: A hold reserves capacity and expires automatically without any user action, tested by abandoning a checkout. An expired hold's capacity becomes available again. A payment arriving after the hold expired is handled explicitly rather than creating an oversold booking. Held seats are unavailable to other customers while the hold is alive. Do not build yet: No payments or cancellation yet. ### Phase 4 · Money, deposits and getting it back Build: Hosted checkout taking a deposit or full payment. Schedule balance collection before departure. Implement the cancellation policy as data with the refund computed and shown before confirming. Handle a departure the operator cancels. Trap: 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. Done when: A deposit collects and a balance is scheduled. The refund shown before cancelling is the amount actually refunded, asserted by a test comparing the quoted and executed figures. An operator cancelling a departure refunds every booking in full regardless of policy. A failed balance payment is retried and surfaced rather than silently cancelling the booking. Do not build yet: No manifests or reporting yet. ### Phase 5 · The manifest, and what the guide needs Build: A departure manifest with passengers, requirements and payment status, available offline and printable. Cutoff enforcement so bookings close before departure. A check-in that works without a connection and reconciles later. Trap: 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. Done when: A manifest is available offline before departure with passengers, requirements and payment status. Check-in works with no connection and reconciles without duplicates. Bookings close at the cutoff. A last-minute capacity reduction is reflected in the manifest and affected customers are identified. Do not build yet: Nothing after this. ### Out of scope (and why) - Distribution through resellers and marketplaces, which is most of what the hosted platforms provide and is a whole integration business. - Dynamic pricing and yield management, which is a pricing product rather than a booking one. - Equipment and staff scheduling, which is a separate resource allocation problem. - Multi-currency settlement and tax handling across jurisdictions, which is genuinely specialist work. ### README must contain - That a departure time means the local time at the activity's location and is stored with its zone - That capacity is taken inside the booking transaction, and why check-then-insert oversells - That holds expire on a timer rather than on a user action - That the refund quoted before cancelling is the refund executed - That the manifest works offline because departure points usually have no signal
# Architecture · FareHarbor ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | capacity is a transactional problem and needs a database that can hold a line | | Capacity | Reserved against the departure row inside the booking transaction | checking availability then inserting is a race that oversells | | Time | Departures stored as an instant plus the activity's own zone | the departure time means the local time where the tour leaves, always | | Payments | Hosted checkout with deposits and scheduled balances | card details on your server is a compliance project rather than a booking system | | Holds | Short expiring holds during checkout, released automatically | a customer entering card details should not lose the seat, and should not hold it forever | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Departures | Schedules, instants and the activity's zone. | Any generator, provided the local departure time is the authoritative fact. | | Capacity | Reservation inside the booking transaction. | Not swappable for check-then-insert, which oversells under exactly the conditions that matter. | | Holds | Expiring reservations during checkout. | Any implementation, provided expiry is on a timer rather than an action. | | Payments | Deposits, balances and refunds. | Any provider with hosted checkout, provided the refund is computed once. | | Manifest | Offline departure lists and check-in. | Any implementation that works with no connection at the departure point. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Bookings, capacity and passenger details, including any stated requirements. - `HOLD_MINUTES` · required · How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. - `BOOKING_CUTOFF_MINUTES` · required · How long before departure bookings close. Per activity in practice, with this as the default. - `STRIPE_SECRET_KEY` · required, secret · Test keys until deposits, balances and refunds are all proven. - `STRIPE_WEBHOOK_SECRET` · required, secret · Verifies incoming events. An unverified endpoint is an open endpoint. - `DEFAULT_TIMEZONE` · required · The default activity zone. Each activity carries its own, which is what actually matters. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · FareHarbor product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Reserved against the departure row inside the booking transaction, Departures stored as an instant plus the activity's own zone, Hosted checkout with deposits and scheduled balances, Short expiring holds during checkout, released automatically. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · FareHarbor Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Departures that mean the right time A departure time is unambiguous for a customer anywhere in the world. ### Steps 1. Store the instant and the activity's zone The local departure time is the fact. The instant is derived from it. Files: `src/departures.ts` 2. Generate schedules across transitions Test both. A season generated naively will be an hour wrong for a week. Files: `src/schedule.ts`, `src/schedule.test.ts` 3. Show local time first The customer needs to know when to turn up where the tour is. Files: `src/display.ts` ### Done when - [ ] One departure shows identically across three zones - [ ] Both daylight transitions generate correctly - [ ] The customer's zone is a hint, not a replacement - [ ] A season generates without duplicates or gaps ### 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. ## M2 · Capacity that cannot be oversold Two simultaneous bookings for the last two seats never both succeed when only one seat exists. ### Steps 1. Reserve inside the booking transaction Against the departure row. Let the database serialise the contention. Files: `src/booking.ts` 2. Test with real concurrency Fifty parallel requests for ten seats. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Record capacity changes with reasons A boat's capacity changes with weather and crew. The reason matters later. Files: `src/capacity.ts` ### Done when - [ ] Fifty concurrent requests for ten seats yield ten bookings - [ ] A capacity failure returns a clear reason - [ ] Capacity changes record a reason - [ ] A full departure is marked rather than counted each time ### 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. ## M3 · Holding a seat while someone pays A customer entering card details does not lose the seat, and does not keep it forever. ### Steps 1. Hold on checkout start, counted against capacity Held is unavailable. Otherwise two people pay for one seat. Files: `src/holds.ts` 2. Expire on a timer, not on an action Abandoned checkouts are the normal case and never come back to cancel. Files: `src/expiry.ts` 3. Handle payment after expiry explicitly It happens. Creating an oversold booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] A hold expires without user action - [ ] Expired capacity becomes available - [ ] Payment after expiry is handled explicitly - [ ] Held seats are unavailable to others ### 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. ## M4 · Money, deposits and getting it back Payments work, deposits are collected, and cancellations refund correctly. ### Steps 1. Use hosted checkout with deposits Card details on your server is a different project entirely. Files: `src/payments.ts` 2. Compute the refund once The quoted figure is the refunded figure. Test that they are the same. Files: `src/refund.ts`, `src/refund.test.ts` 3. Operator cancellation refunds fully The policy governs customer cancellation. It does not govern yours. Files: `src/cancel.ts` ### Done when - [ ] A deposit collects and a balance is scheduled - [ ] The quoted refund equals the executed refund - [ ] Operator cancellation refunds every booking fully - [ ] A failed balance payment is retried and surfaced ### 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. ## M5 · The manifest, and what the guide needs (production only) The person running the tour has an accurate list, including offline. ### Steps 1. Make the manifest available offline Downloaded before departure. There is no signal at the dock. Files: `src/manifest.ts` 2. Queue check-in locally And reconcile without duplicates when a connection returns. Files: `src/checkin.ts` 3. Enforce the booking cutoff Nobody can be added to a boat that has left. Files: `src/cutoff.ts` ### Done when - [ ] The manifest is available offline with all details - [ ] Check-in works offline and reconciles cleanly - [ ] Bookings close at the cutoff - [ ] A capacity reduction identifies affected customers ### Watch 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.
# Operations · FareHarbor ## Backup Bookings are money owed and services promised. Nightly backups with a tested restore, and consider that passenger requirements may include health information, which raises the sensitivity of the whole table. ## Restore Restore and confirm capacity counts match the bookings, since a restore that loses recent bookings leaves seats appearing available that customers have already paid for. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Oversell attempts and hold expiry rate. A rising rate of holds expiring without conversion usually means the checkout is failing rather than that customers changed their minds. ## Incident checklist An oversold departure is the incident that reaches a customer at the dock. It means capacity was taken outside the transaction, and the concurrency test is the control that should have caught it. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Fifty concurrent requests for ten seats are confirmed to yield ten bookings - [ ] Both daylight transitions are confirmed to generate correct departures - [ ] A hold is confirmed to expire without user action - [ ] The quoted refund is confirmed equal to the executed refund - [ ] The manifest is confirmed available offline ## Launch constraint Do not market omitted FareHarbor capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Bookings, capacity and passenger details, including any stated requirements. DATABASE_URL=postgres://localhost:5432/bookings # Required. How long a checkout holds a seat. Long enough to pay, short enough not to block a departure. HOLD_MINUTES=10 # Required. How long before departure bookings close. Per activity in practice, with this as the default. BOOKING_CUTOFF_MINUTES=120 # Required · secret. Test keys until deposits, balances and refunds are all proven. STRIPE_SECRET_KEY=sk_test_... # Required · secret. Verifies incoming events. An unverified endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The default activity zone. Each activity carries its own, which is what actually matters. DEFAULT_TIMEZONE=Europe/Lisbon
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the fee is usually charged to the traveler, not the operator, so it never shows up as a line item on the operator's P&L the way a subscription would, and because the alternative to a channel manager is manually keeping availability in sync across five OTAs while running trips. Operators also pay for the absence of liability: when a card is disputed or a payout is late, that is not their engineering problem. A DIY build is a real option for a direct-sales operator with a stable product catalogue and someone technical on the team, and it is a bad option for anyone whose seats are filled by resellers.
xOTA and reseller distribution: live availability pushed to Viator, GetYourGuide, Expedia and dozens of local agents, plus the commission accounting behind it
xSomeone else owning payment risk: chargebacks, disputes, refund flows, payout timing, PCI scope and multi-currency
x24/7 human support during a season where a broken booking page costs real departures that day
xThe 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
xMobile check-in that works on a beach with one bar of signal, and the reporting your accountant already knows how to read
Nothing worth pointing at. That's why the prompt exists.
Vibecode FareHarbor
Kinda. The core of FareHarbor is buildable in a weekend with the prompt on this page, but there are real gaps: 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. Read the honest list above before committing.
How much does FareHarbor cost?
FareHarbor's pricing is usage-based or varies by plan · No monthly software fee for operators; the fee (commonly cited around 6% in North America, negotiable 5-8%) is added to the traveler's checkout total. OTA-sourced bookings are billed to the operator separately..
What do I lose by replacing FareHarbor?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to FareHarbor?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.