Skip to content

Architecture Overview

A 30,000-foot view of the Alhuda Travel ERP — what it is, who uses it, how the layers fit together, and the invariants that hold the design up.

What this app is

Alhuda Travel ERP is an internal, staff-and-partner-facing system that runs the day-to-day operations of a travel agency specialising in Hajj / Umrah and allied travel. It is not a customer-shopping frontend — it is the back-office that:

  • Tracks leads and quotations through to confirmed bookings.
  • Plans and dispatches group tours (flights, hotels, ground transfers, visas, tickets).
  • Manages B2B partners, suppliers, hotels, airline block inventory, and FIT packages.
  • Runs the finance function: double-entry journals, vouchers, invoices, GST + TDS compliance, AR/AP aging, and bank reconciliation.
  • Gates every action through a role-and-permission matrix that is enforced at the UI, API, and database layers.

Who uses it

Audience Typical surface
CEO, GM Dashboards, approvals, admin settings, permission matrix
Finance (Finance Manager, Accountant, Cashier) /finance — journals, vouchers, reports, reconciliation, TDS
Sales (Sales Manager, Sales Executive) /sales/* — leads, quotations, bookings, customers
Ops (Ops Manager, Ops Executive) /groups, /inventory/*, approvals queue, corrections queue
Ticketing / Visa officers /tickets, /visa pipelines
B2B partners (AGENT role) /partner/* portal (self-service bookings, inventory, invoices)
Customers (CUSTOMER role) /customer/* portal (own bookings, documents, payments)

Portal users (AGENT, CUSTOMER) are role-gated and ownership-scoped; they never hold staff permissions. See Permissions system.

Component diagram

flowchart LR
    subgraph Browser["Browser (SPA)"]
      UI["React 18 + Vite bundle<br/>Radix UI, Tailwind, React Query"]
      SW["Service Worker (PWA)<br/>src/sw.ts"]
    end

    subgraph Edge["Cloudflare Pages"]
      Pages["Static SPA assets<br/>_headers (CSP/HSTS)"]
    end

    subgraph ApiLayer["API — single handler monolith"]
      API["src/lib/api.ts<br/>(~28k LOC, requirePermission gated)"]
    end

    subgraph Supabase["Supabase"]
      PG[(Postgres + RLS<br/>auth_user_has_permission)]
      AuthSvc["Supabase Auth (JWT)"]
      Storage["Supabase Storage<br/>(receipts, visa docs)"]
      EF["Edge Functions<br/>razorpay-webhook,<br/>admin-users, mailer,<br/>whatsapp-webhook, ..."]
    end

    subgraph External["External integrations"]
      RZP["Razorpay<br/>(payments)"]
      GSTN["GSTN portal<br/>(stub today)"]
      MSG["Email / WhatsApp / SMS<br/>(via Edge Functions)"]
      Sentry["Sentry<br/>(errors + perf)"]
    end

    UI -->|PostgREST / RPC via<br/>supabase-js| API
    UI -->|auth| AuthSvc
    UI --> SW
    Pages --> Browser
    API --> PG
    API --> Storage
    API --> EF
    EF --> PG
    EF --> RZP
    EF --> MSG
    API -. read-only stub .-> GSTN
    UI --> Sentry

The API is a library, not a server

src/lib/api.ts runs inside the browser bundle and talks to Supabase via the anon-key client (src/integrations/supabase/client.ts). It is the single choke-point where every write passes through requirePermission() before hitting PostgREST. Heavyweight or privileged actions live in Supabase Edge Functions (see supabase/functions/).

Key architectural invariants

1. Single-handler API monolith

All app-initiated writes funnel through src/lib/api.ts. At ~28k lines it is deliberately monolithic — the trade-off is intentional:

  • Every mutation gets the same permission check, audit hooks, and error shape. Splitting it into modules historically caused permission checks to drift; the single file is the enforcement surface.
  • Idempotency guards co-locate with the mutation. See the ops-approve / finance-approve short-circuits (src/lib/api.ts:11319, src/lib/api.ts:13579) and the voucher-level idempotency keys (src/lib/api.ts:2045-2155).
  • New routes go here, not into a new file. See CLAUDE.md "API" section.

Do not create parallel handlers

A second "lib/api-v2.ts" would bypass the permission-check discipline. If you need to introduce a new call, add it to the existing file.

2. RLS-enforced permissions (defense in depth)

Every write goes through await requirePermission('x.y') in the handler and a Postgres RLS policy using auth_user_has_permission('x.y'). The RLS layer is the second wall: even a stolen JWT that bypasses the UI will still hit the DB check.

  • Client enforcement — <ProtectedRoute> (route-level) and <PermissionGate> (action-level).
  • API enforcement — requirePermission() in src/lib/api.ts:136-142.
  • DB enforcement — RLS policies using auth_user_has_permission() declared in supabase/migrations/20260415200000_permission_aware_rls.sql and successors.

Details: Permissions system, RLS.

3. Double-entry accounting

All finance mutations post balanced JournalEntry + JournalLine pairs. Every line row carries either a debit or credit; the sum of debits always equals the sum of credits for a given entry. Source: supabase/migrations/20260113130121_remote_schema.sql:323-341 and the posting helpers in src/lib/financeOperations.ts.

Vouchers (receipt, payment, contra, journal) in this codebase are numbered JournalEntry rows, not a separate table — see supabase/migrations/20260330170000_account_hierarchy_and_vouchers.sql which adds voucherNo to JournalEntry.

4. Maker-checker on journal approvals

A user who creates a journal entry cannot approve, reject, or reverse it by default. Override permissions finance.journals.approve_own and finance.journals.reverse_own exist only for break-glass super-admin use (CEO / GM / IT_ADMIN) — see supabase/migrations/20260418150000_journal_maker_checker_overrides.sql.

5. Idempotency guards on approvals and postings

Double-clicks, retried network calls, and webhook replays are treated as first-class threats. Three patterns:

  • Approval short-circuits. ops-approve and finance-approve inspect the current booking state and return early if already in the target state, never posting duplicate journals.
  • Voucher idempotency keys. Vouchers compute a deterministic key from the payload and stamp [idem:<key>] into LedgerEntry.notes / JournalEntry.memo. Re-posts are matched by that marker and deduped (src/lib/api.ts:2080-2155).
  • Razorpay webhook dedup. Payment.razorpayPaymentId is UNIQUE — the webhook (supabase/functions/razorpay-webhook) uses it as the dedup key (supabase/migrations/20260418114458_razorpay_payment_fields.sql).

Deployment surface

Surface Where Notes
SPA (static) Cloudflare Pages Hosts the Vite-built bundle plus public/_headers for CSP/HSTS. PWA service worker served from the same origin.
Database Supabase (managed Postgres) All schema lives in supabase/migrations/*.sql. RLS enabled on every user-facing table.
Edge Functions Supabase razorpay-webhook, admin-users, mailer, whatsapp-webhook, push-send, finance-copilot, lead-intake, session-manager, upload-*, extract-passport — see supabase/functions/. Used for privileged work (service-role key) and inbound webhooks.
Storage Supabase Storage Customer docs, visa docs, receipts, chat uploads.
Observability Sentry (@sentry/react) Wired from src/lib/observability.ts and src/components/common/ErrorBoundary.tsx.

No traditional backend server

There is no Node/Express/NestJS tier. The browser bundle calls Supabase directly; privileged work goes to Edge Functions. This keeps the deploy topology small (Cloudflare Pages + Supabase) but makes requirePermission() + RLS non-optional.

Where to go next

  • Stack — concrete library choices and why.
  • Data model — core entities, lifecycle states, and the permissions that gate them.
  • Permissions system — how the three layers fit and how to add a permission.
  • RLS — writing Postgres policies that agree with the matrix.
  • Frontend routing — the URL-to-page-to-permission map.