Skip to content

Case study

Simply Happy: retreat booking and payments

I took Simply Happy from email bookings and a WordPress site to a live booking and payments platform, and I own what runs behind it.

Summary

Before

Bookings by email. A WordPress brochure site.

After

A live booking platform with deposit and final payments, and an admin back office used daily by three admins.

  • SvelteKit
  • TypeScript
  • tRPC
  • PostgreSQL
  • Stripe
  • Playwright

What I owned

  • Requirements

    I worked with the client from the first consultation, gathered the requirements and turned the business needs into a technical plan I could build against.

  • Architecture

    I designed and built the platform in SvelteKit, TypeScript and tRPC inside a pnpm monorepo, with PostgreSQL through Drizzle ORM, Supabase Auth for sign-in, AWS S3 for file storage and Resend for email notifications. I set the technical standards and development workflow.

  • Booking and payments

    I built the booking system with real-time availability and the Stripe integration for deposit and final payments, along with service scheduling and event management.

  • Admin

    I built the admin back office for bookings, user approvals, pricing and analytics, with role-based access control. It also carries a read-only AI help panel limited to seven fixed lookups.

  • Team

    I hired a designer and coordinated the Figma-to-code work across the project. I keep direct contact with the client's marketing team as features such as the newsletter ship.

  • Tests

    I set up Playwright end-to-end tests covering the booking flows, authentication and the admin, so the product is tested like a team product.

The booking and payments flow

I take payment for a stay through Stripe Checkout. Once a guest has chosen dates and a room, I create a Checkout session on the server and send the guest to Stripe to pay. I keep the booking's payment pending until Stripe reports that the session was paid.

Right before that session is created, I re-validate availability on the server. The availability a guest saw while browsing is only a hint; the check that counts runs at the moment of checkout, so a room taken while the guest was deciding is caught before any payment is taken.

I decide how much to take now from the arrival date. I take full payment when arrival is within 30 days, when the guest chooses to pay in full up front, or when the stay is booked with an event attached. Further out, I take a deposit and create a Stripe invoice for the balance, due 30 days before arrival.

A daily job I wrote collects the balance. It picks up the invoices whose charge date has passed and charges them, so I never wait on the guest coming back to the site for the final payment.

  1. PendingThe Checkout session is open and nothing is confirmed yet.
  2. Stripe's webhook confirms the deposit
  3. Deposit confirmedStripe's webhook has confirmed the deposit; arrival is more than 30 days away.
  4. A daily job charges the balance
  5. ConfirmedA daily job has charged the balance and the stay is fully paid.
The payment states of a stay booked more than 30 days before arrival. Within 30 days of arrival the session takes full payment and the webhook moves the payment straight from Pending to Confirmed. Failed and refunded states are left out.

Reliability decisions

  • Idempotent webhook

    Stripe can deliver the same event more than once, so the confirm step is safe to repeat: it reads the payment's status first and does nothing when it is already confirmed or deposit-confirmed. The success page a guest lands on runs that same confirm as a second path, so a stay is confirmed whichever of the two arrives first.

  • Recovery sweep

    A webhook can also fail to arrive. For stays, the success page runs the same idempotent confirm, so the guest's return completes it. For bookings tied to an event, a daily job sweeps the payments still pending, checks with Stripe whether each session was paid, and confirms the ones that were.

Decisions and rejected alternatives

  • Admin AI assistant: how it reads data

    Chose
    Seven fixed read-only lookups. Each one checks that the caller is an admin before it reads anything, validates its input and selects an explicit list of columns, so the model only ever sees the fields I chose for it.
    Rejected, because
    Read-only SQL. A wrong join looks right in a chat answer, and a free query could reach tokens and allergy data. A fixed set of lookups is testable instead: one test walks every lookup and asserts it never touches a forbidden table or returns a forbidden field.

    Where this lives: the shared backend's assistant module and its lookup tests

  • Admin AI assistant: where the lookups come from

    Chose
    Allow-listed columns, pages of twenty rows and guest-written text quoted rather than trusted, in lookups I wrote for the assistant alone.
    Rejected, because
    Reusing the admin screens' tRPC endpoints. They return whatever a screen needs, such as every booking with its guests' personal details, which is far more than any question requires and far more than I wanted a model to see.

    Where this lives: the assistant lookups module and its column allow-list

  • Admin AI assistant: what it remembers

    Chose
    Conversations live in the browser tab only, in session storage. The server streams the answer and stores nothing, and a test asserts that neither the question nor the answer is logged.
    Rejected, because
    Storing chats. Saved conversations about guests become personal data the business has to search, export and erase on request, and an assistant that remembers nothing has nothing to leak.

    Where this lives: the admin assistant panel component and its stories, plus the request handler's tests

  • One offerings model instead of the services engine

    Chose
    A single offerings model in PostgreSQL, with staff, shifts and supplies tied to offerings and the booking rules written as pure functions I can unit test.
    Rejected, because
    Keeping the services engine. Its data model had drifted: staff assignment lived in two tables that disagreed, stock was static and nothing consumed it, analytics summed list prices, and four charts never rendered. Two migrations dropped it and a replay test proves the old tables are gone.

    Where this lives: the two drop migrations and the services-engine-dropped test

  • Paid offerings: confirmed by the webhook, not the redirect

    Chose
    For offerings paid on the site, the reservation is written only when Stripe's webhook arrives, through a locked SQL function keyed on a unique payment id, so a duplicate delivery can never book twice. The success page only reads the result. Stays take a different route: an idempotent confirm step reachable from both the webhook and the success page.
    Rejected, because
    Trusting the client redirect. The old engine wrote bookings only when the browser reached the success page, so confirmation depended on the customer's tab staying open.

    Where this lives: the offerings webhook handler and the reserve-places SQL function

Testing and delivery

approx., as of 18 Oct 2026

Playwright spec files
94
Playwright tests
about 394
352 in the default run; the rest are the opt-in UI tests and the payment tests
Unit test files
158
about 2,000 cases across the web app and the shared package
Storybook story files
187
covering 172 components; 177 of the files have play functions

Every change starts as a Linear issue. I branch from the issue, open a pull request into the epic branch, and the epic branch goes into develop as one pull request once its stories are in. Develop reaches main only through a release pull request, hotfixes branch from main, and the issue closes when its pull request merges.

Git hooks, installed with the dependencies, enforce that flow. The pre-commit hook rejects a commit on main or develop, and the pre-push hook rejects a push to either and refuses a branch whose name does not carry an issue key, hotfix branches aside, so work can only land through a pull request. The hooks run no checks of their own; that is CI's job.

CI runs only on pull requests into develop and main: lint, type checks, the stories-coverage audit that fails when a component has no story, a production build and the Playwright integration tests on Chromium. The Storybook test runner, the opt-in UI tests and the shared package's unit suite run locally only, so a green pull request means the integration suite passed, not that every suite did.

Admin AI assistant (safety design)

The admin back office carries an AI assistant: a read-only help panel where an admin asks a plain question about bookings, arrivals, unpaid items or guests and gets an answer drawn from the live data. It is launched and in use by the client's admins from the back office. I designed it so that it cannot write, cannot see the sensitive fields, cannot remember, cannot run up an unbounded bill and can be switched off. The safeguards below are how.

  • Read-only scope

    The model can call seven fixed read-only lookups and nothing else: it receives only those seven tool definitions, and any other name is rejected. Each lookup checks that the caller is an admin before it reads anything, validates its input and selects an explicit list of columns, so the model only ever sees the fields I chose for it. Guest-written text is quoted to the model rather than trusted.

  • No stored chats

    Conversations live in the browser tab only, in session storage. The server streams the answer and stores nothing, and a test asserts that neither the question nor the answer is logged. Closing the tab ends the conversation, so there is no archive of questions about guests to search, export or erase.

  • Fields that are never sent

    Health data (allergies), date of birth, gender and tax number are among the fields never selected and never returned by any lookup, together with payment ids and guests' free-text answers. One test runs every lookup and asserts it never touches a forbidden table or returns a forbidden field, and the system prompt tells the model these fields are not available to it. The meals lookup returns only a count of bookings with dietary restrictions.

  • Spend bounds

    Spend is bounded in code: five lookups per question, capped output for each answer, a bounded history sent with each turn and a cap on the length of a message. On top of that, a monthly spend cap set in the provider's configuration stops the assistant when it is reached, and the admin sees a plain message that the monthly limit has been hit.

  • Admin off switch

    An admin can turn the assistant off from the back office, and off is the default. The setting is read before any model call, so a fresh deployment starts with the assistant disabled until an admin turns it on, and turning it off takes effect on the next question.

I chose fixed lookups over read-only SQL because a wrong join looks right in a chat answer, and a free query could reach tokens and allergy data. A query that quietly pairs one guest's details with another guest's booking produces a confident, plausible reply, and an admin reading it has little way to spot the mistake. Seven lookups with fixed field lists are less flexible, but each one is testable and admin-checked, and the whole surface the model can reach is small enough to read in one sitting.

Screenshots (test data)

Captured from the develop branch running against seed data, so the names and prices are test values. Select a screenshot to see it larger.

  • Test data. Room selection step of the booking flow
  • Test data. Checkout summary before Stripe
  • Test data. Admin booking calendar with the assistant panel open
  • Test data. Admin events management, redesigned list
  • Test data. Supplies: stock on hand, reorder points and deliveries
  • Test data. Rules for bookings paid on the site: auto-confirm and the cancellation window
  • Test data. Admin events on a phone