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-approveshort-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()insrc/lib/api.ts:136-142. - DB enforcement — RLS policies using
auth_user_has_permission()declared insupabase/migrations/20260415200000_permission_aware_rls.sqland 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-approveandfinance-approveinspect 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>]intoLedgerEntry.notes/JournalEntry.memo. Re-posts are matched by that marker and deduped (src/lib/api.ts:2080-2155). - Razorpay webhook dedup.
Payment.razorpayPaymentIdis 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.