MoviFit
Class booking and memberships for movement studios
Booking, credit-based memberships and payments for pilates, yoga and barre studios. Pura Vida Moves, a boutique studio in Guanacaste, runs 25 weekly classes and 61 enrolled students on it, with the business rules enforced in PostgreSQL and tested with pgTAP.
Screens
Tech Stack
Overview
Pura Vida Moves is a boutique movement studio in Guanacaste, Costa Rica: 16 classes, 25 weekly time slots, 8 instructors, adults and kids. Enrolment, payments and attendance were handled over WhatsApp and by hand. MoviFit replaced that. Students book classes and buy packages from their phone, guardians manage their children's classes, instructors take attendance, a tablet at the door works as a check-in kiosk, and the studio gets its own public site. I started the repository in late August 2026, and within weeks the studio was running real bookings and payments on it.
⚡ The Challenge
A class credit is money. Students buy packages that expire, gift credits to friends, cancel late or don't show up, and every one of those has to be explainable to a customer months later. Two people can try to take the last seat in the same second. A shared tablet at the front desk must let anyone check in without exposing anything about anyone. And the public site must never become a way to read the database.
✓ The Solution
I wrote the architecture down first as ADRs, 14 so far, each with the alternative I rejected. The browser only reads and calls functions: every write is a PostgreSQL RPC, so rules, transactions and the audit log live in one place. Credits are an append-only ledger; booking holds a credit and attendance consumes it; the last seat is decided by a single conditional UPDATE in the database. Everything is tested where it lives, with pgTAP, and the capacity race is tested with ten parallel connections fighting for one seat.
Impact & Results
At Pura Vida Moves, booking classes and managing monthly memberships in the app
16 classes and 8 instructors across two programmes
55 database test files run in CI, next to Vitest and Playwright
Key Decisions
React SPA, not Next.js (ADR-001)
The business logic lives in Postgres, so the only thing Next.js would add is SSR for SEO, at the cost of server and client components and cache layers. SEO went to a separate Astro site instead.
Credits are a ledger, not a counter (ADR-002)
The balance is the sum of non-expired rows, spent first-in first-out by expiry date. Nothing is ever edited or deleted: a correction is a new row. Triggers block UPDATE and DELETE, and a statement-level trigger blocks TRUNCATE, which row-level triggers never see.
Booking holds the credit, attendance spends it (ADR-006)
A late cancel or a no-show only costs a class if the class was full, because that is when it kept somebody else out. The last-seat race is resolved with one UPDATE ... WHERE reserved_count < capacity, not in React.
The client reads; writing is calling a function (ADR-011)
Row-level security answers which row, not which column. An audit found that a table-wide UPDATE grant let a client move a student to another organisation. The grant was narrowed, and any insert, update or delete in the frontend now has to be justified in the pull request.
A kiosk with a minimal role (ADR-008)
The front-desk tablet holds a device token, not a logged-in admin. It can do exactly three things: list today's sessions, search active students by name, and insert one attendance row. It resets after 10 seconds so the next person never sees the previous name.
The public site sees one view (ADR-013)
Each studio gets a site on its own subdomain, rendered by Astro on Cloudflare Workers. It can read a single Postgres view and four public functions, so the question of what the public site can see has a one-screen answer. A database outage returns 503, never 404, so Google does not drop the studio.
Technical Highlights
- ▸ React 19, Vite, TanStack Router and TanStack Query, Tailwind v4, installable PWA
- ▸ 128 migrations; SECURITY DEFINER RPCs with EXECUTE revoked from PUBLIC, after measuring that a grant to authenticated did nothing to keep anonymous callers out
- ▸ pgTAP with 55 files and 851 assertions, Vitest for units, and Playwright end to end through the real magic-link login read from a local inbox
- ▸ One CI job ordered by what is cheapest to be wrong about: secret scan, typecheck, lint, unit tests, then a database reset from migrations, pgTAP, the capacity race test, a diff of the generated types, end-to-end tests, build, and a scan of the compiled bundle for leaked keys
- ▸ Five pg_cron jobs (session generation 21 days ahead, membership expiry, closing finished sessions, expiry notices, email dispatch) and four Deno Edge Functions
- ▸ Guardian and ward accounts with a waiver signed per student and a permission flag for managing payments
Key Learnings
A student wrote that a class had been deducted twice, once as a no-show and once as attended. The fix was a new ledger row that returns the credit in the same transaction, plus an idempotent migration that repaid everyone already affected, proven by running it twice in a test.
I granted EXECUTE to authenticated and assumed anonymous callers were out. They were not: Postgres grants EXECUTE to PUBLIC by default, and two of my three fixes only looked like they had worked. Measure privileges, do not assume them.
A confirmation email went out with a broken link. The function logs showed that the version serving production had never been built from the repository. Anything deployed by hand eventually disagrees with git.
The kiosk registration link could only be copied on the administrator's computer, never on the tablet across the room. The only path that worked was the one my end-to-end test used. A QR code fixed it.
Writing the rejected alternative next to every decision is what makes the decision reviewable a month later.