Skip to content

2026 · Solo full stack · freelance · shipped

Travel Booking Platform

Custom booking and back-office platform for a Malaysian travel agency. Dual-rail FPX and manual-transfer checkout with idempotent webhooks, a reseller commission engine, RLS-hardened multi-portal access, and server-rendered invoices.

~30 days catalog, checkout, agent portal, and ops console, end to end

  • Next.js 16
  • React 19
  • TypeScript
  • Supabase (Postgres)
  • toyyibPay (FPX)
  • pdfkit
  • Vercel

A small travel agency was running its business on a static WordPress site and a lot of spreadsheets. Bookings came in by WhatsApp, payments were bank transfers verified by eye, and the sales agents who brought in most of the customers were tracked by hand. The brief was to replace all of it with one system: a public catalogue people could actually pay through, a back office the staff could run without a developer, and a reseller portal that paid agents the right commission automatically. Built solo, end to end, in about a month.

Context

What it is

A custom booking and management platform for a Malaysian travel agency that runs Muslim-friendly group tours. It replaces a WordPress/WooCommerce site with a transactional system: a Malay-language public catalogue with self-serve checkout, and three authenticated portals behind it — an admin console (full CMS plus operations), a staff portal (bookings, receipt verification, customer and inquiry handling), and an agent portal where resellers generate trackable payment links and earn commission.

A customer browses packages, picks a dated departure, builds a per-traveller manifest, and pays a deposit or balance over Malaysian FPX online banking, with a manual bank-transfer fallback for the customers who do not pay online. The old WordPress content was scraped and migrated into a structured Postgres catalogue so nothing was retyped.

The thing that mattered most was that the agency could run the whole operation themselves after handover. Non-technical staff schedule departures, edit packages, verify receipts, and approve agent payouts without touching code.

Architecture

One database, four audiences

The platform is a single Next.js 16 App Router application on Vercel with Supabase Postgres underneath — no separate API server, just server actions and a few route handlers for the payment callback, PDF rendering, and CSV export. The interesting part is not the stack, it is that one database serves four very different audiences (public customer, admin, staff, agent) and each one has to see exactly what it is allowed to and nothing else.

Role is resolved server-side on every request: a user is admin or staff (via a staff-roles table) or an agent (via the agents table), and layout guards redirect cross-role access. Every table has row-level security on, with helper predicates (is_admin, is_staff, current_agent_id) used inside the policies so an agent can read only their own bookings, commissions, payouts, and links. Money math runs in integer sen to avoid floating-point drift, and the pricing, commission, and attribution logic are pure, unit-tested functions kept separate from the database layer that enforces the same invariants with triggers.

01 · Dual-rail payments, idempotent on the payment

Checkout runs over two rails that converge on one confirmation function. The FPX rail computes the amount server-authoritatively (tiered adult/child/infant pricing, per-pax deposit), creates a bill with the toyyibPay gateway, and redirects. A webhook callback then confirms the booking — but only after verifying the gateway's hash, so a forged or misconfigured callback gets a 403 and a log entry, not a silent success. The manual rail lets a customer upload a bank-transfer receipt that staff verify in the dashboard.

The subtle bug was in how idempotency was keyed. The first version guarded the callback on booking status, and it silently dropped balance confirmations: once a booking was deposit_paid, a later callback for the balance bill was treated as a duplicate and ignored. The fix was to key idempotency to the payment row — the unique bill code and the verified state of that specific payment — not the booking.

toyyibpay/callback
// A retried callback for an already-verified bill is a no-op.
// A still-pending balance bill confirms even while the
// booking already sits in 'deposit_paid'.
const payment = await findPaymentByBillCode(billCode)
if (!payment) return res.status(404)
if (payment.status === 'verified') return res.status(200) // idempotent

if (!verifyToyyibpayHash(payload, secret)) {
await logSuspiciousCallback(payload)
return res.status(403)
}
await confirmPayment(payment) // shared by FPX + manual rails

There is also an ordering rule worth naming: on first payment the booking flips to deposit_paid first, which fires the slot-deduction trigger (and can raise "departure full"), and the payment is marked verified only after the slot is secured. So a departure that fills up at the last moment never strands a verified payment on a pending booking, and a cancelled or completed booking is never resurrected by a late callback.

02 · A reseller engine that survives real-world attribution

Most of the agency's customers come through sales agents, so the attribution had to be right or someone gets paid for a sale they did not make. First click is captured in cookies via a public beacon. A deep link prefills the agent-code field with its own agent's code, which is not an override. Only a genuinely different code, typed in manually, displaces the prior first-click — and when it does, the displaced reference is preserved in an audit log. Staff bookings made on behalf of a customer deliberately ignore the staff member's ambient cookie so they do not mis-attribute.

The displacement rule is a pure function with its own unit tests, because it is exactly the kind of edge case that ships broken. Commission accrual is a database trigger: it fires at an admin-configured status (deposit_paid or fully_paid), reads admin-tunable rates from a settings row, applies per-package overrides, skips house and inactive agents, and reverses itself if the booking is cancelled. It is idempotent on a unique booking id, so the same booking can never accrue commission twice.

03 · Locking down the database, one advisor lint at a time

Row-level security was not a one-shot. It was a deliberate hardening arc across several migrations: pinning search_path on functions, moving role checks into SECURITY DEFINER helpers, and then an explicit lock-down that revokes EXECUTE on those definer functions from the anon and authenticated roles and re-grants only to the service role. Each step was driven by Supabase's own security advisor lints, and each function kept callable by the public (the RLS predicates, the attribution beacon) versus locked to service-role-only got a written justification in the migration.

Passport scans — which the manifest requires — are stored as private-bucket keys rather than URLs and served through short-lived signed URLs, and the anonymous pre-login upload action is throttled per IP. The principle throughout: forgetting a policy should fail closed, not leak.

  • 4

    audiences on one database: customer, admin, staff, agent

  • 2

    payment rails (FPX + manual transfer) into one confirm function

  • sen

    all money math in integer sen, no float drift

  • ~30 days

    solo build from kickoff to working platform

  • RSC

    Next.js 16 App Router, server actions, no separate API tier

  • 0

    developer involvement required for day-to-day operations

Learnings

  1. Idempotency belongs on the payment, not the booking. Guarding the gateway callback on booking status silently dropped balance confirmations once a booking was deposit_paid. Keying it to the unique bill code and the verified state of that specific payment fixed it. Webhooks retry; the question is always 'idempotent on what?'.
  2. Secure the slot before marking the payment verified. Flipping the booking to deposit_paid first lets the slot trigger raise 'departure full' before a payment is finalised, so a last-minute sell-out never strands a verified payment on a pending booking. Ordering of side effects is a correctness property, not a detail.
  3. Attribution is mostly edge cases. Deep-link prefill, manual-code override, staff-on-behalf bookings, and preserving the displaced reference in an audit log were each a separate decision. Writing the displacement rule as a pure, tested function was the only way to trust it.
  4. Let the security advisor drive the lockdown. Iterating RLS migration by migration against Supabase's advisor lints, with a written rationale for every function left public versus revoked, produced a least-privilege posture I could actually defend, instead of a one-shot guess.

FAQ

Why one database for four different portals instead of separate services?
Because the data is the same data seen from four angles. A booking is a booking whether the customer, the agent who referred it, the staff member verifying the receipt, or the admin reading the dashboard is looking at it. Splitting that across services would mean syncing state and re-deriving permissions in multiple places. Keeping it in one Postgres with row-level security means the access rules live in exactly one place and are enforced by the database, not by application code that can drift.
Why toyyibPay for payments?
It is a Malaysian gateway with first-class FPX online-banking support, which is how a large share of Malaysian retail customers actually pay. The original scope named a different gateway; a documented change order swapped it for toyyibPay during the build. The webhook hardening (hash verification, idempotency on the bill code) is gateway-agnostic and would carry over to any provider.
Can the agency run this without a developer?
That was the point. Non-technical staff schedule departures, edit packages through a tabbed editor with draft preview, verify receipts, manage the agent program, and approve payouts with proof of transfer, all from the admin and staff consoles. A daily scheduled job handles booking lifecycle transitions automatically. The only thing that needs an engineer is a new integration.