Customers, Partners & Suppliers
Written April 2026 — read this first
Still broadly right. Customer lists page and search in the database and mask passport numbers to the last four characters outside the customer's own record (AUD-020). The one-page customer view is now get_customer_360 at /customers/:id — see Customers and Customer 360. Supplier purchases and payments both settle that supplier's own SUP- ledger, not the 2100 control account.
Party-level endpoints: end customers, B2B partners (agents), suppliers, leads, and the unified "people" surface.
Customers
Handlers: handleSalesCustomers at src/lib/api.ts:6992,
handleSalesCustomersById at src/lib/api.ts:7220,
handleCustomerSync at src/lib/api.ts:7123,
handleSalesCustomerPassengers at src/lib/api.ts:7303,
handleSalesCustomerDocuments at src/lib/api.ts:7381,
handleSalesCustomerDriveDocuments at src/lib/api.ts:7455,
handleSalesCustomerLedger at src/lib/api.ts:7515,
handleCustomerPasswordRoute at src/lib/api.ts:8014.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/sales/customers |
GET | (read-only) | List customers |
/sales/customers |
POST | customers.create |
Create or revive soft-deleted customer (keyed by passport) |
/sales/customers/sync |
POST | customers.edit |
Sync customer rows with auth users (mirror User → Customer) |
/sales/customers/:id |
GET | (read-only) | One customer with full profile |
/sales/customers/:id |
PATCH | customers.edit |
Update customer profile |
/sales/customers/:id |
DELETE | customers.delete |
Soft-delete (deletedAt) |
/sales/customers/:id/passengers |
GET, POST | customers.edit (POST) |
Saved passengers linked to the customer (family members) |
/sales/customers/:id/passengers/:pid |
PATCH, DELETE | customers.edit |
Update or remove a saved passenger |
/sales/customers/:id/documents |
GET | (read-only) | Documents attached to the customer |
/sales/customers/:id/drive-documents |
GET, POST | customers.edit (POST) |
The customer's CustomerDocument rows. The desktop no longer POSTs here: upload-customer-doc stores the file and records the row itself. |
edge function upload-customer-doc |
POST | customers.edit or customers.create; or a signed-in traveller for their own customer record only (Customer.userId = caller, TRV-005) |
Files one document on the company drive and inserts the CustomerDocument row. Body: customerId, docType, fileName, mimeType, fileData (base64), customerName, optional passengerId (must be a passenger the caller may see and, where linked, on the same customer — else 400). The app's passport scan calls it. |
edge function drive-file |
POST, GET | see Files on the company drive | Opens one file inside the ERP or the app with a ten-minute link to itself. |
edge function issue-document |
POST | tickets.view and bookings.view for eticket; hotels.view and bookings.view for hotel_voucher (TRV-013) |
Issues the company's e-ticket sheet or hotel voucher for a booking as a PDF. Body: kind (eticket | hotel_voucher), bookingId, optional assignmentId (one stay; otherwise every stay of the booking gets a voucher). Answers { documents: [{ id, kind, label, refId, storagePath, fileName, issuedAt, url }], url, expiresIn: 600 } (url: a ten-minute drive-file link). Stores the PDF on the company Shared Drive under Issued/<booking> (ACC-074), records it through issue_document_record with its Drive file id (driveFileId; storagePath holds drive:<id>) (supersedes the previous live file for the same booking, kind and stay), and returns the file just issued for a second identical call within a minute. 403 without both permissions (the message names them); 404 unknown booking; 409 with the reason — a traveller with no issued ticket number (named), no flight on the departure, no hotel stay, a booking not confirmed by finance or cancelled (LC-004). The native app's booking screen calls it. |
edge function issue-document (receipt) |
POST | finance.view or bookings.view; or the booking's own customer or payer, or its partner (FIN-045) |
The company's receipt for one verified payment as a PDF. Body: kind: 'receipt', paymentId, optional reissue: true (staff only: replace the live receipt). Answers the same shape as above plus reused (true when the live receipt was handed back rather than made). The live receipt of the payment is returned as it is; a new one is rendered, stored under Issued/<booking> and recorded through issue_document_record (kind receipt, refId = the payment id) only when there is none, or on reissue. 400 without paymentId; 403 for anyone else — the same answer whether or not the payment exists — and for a paused account asking for a new file; 404 unknown payment (staff); 409 with the reason — not verified (pending, rejected, refunded), no positive amount (a refund), or a payment against a group invoice with no booking. The native app's staff booking, partner booking and the traveller's Money card, and the desktop booking page's Payments tab call it. |
/sales/customers/:id/drive-documents/:docId |
DELETE | customers.edit |
Remove a drive doc |
/sales/customers/:id/ledger |
GET | (read-only) | Customer ledger with running balance |
/sales/customers/:id/password |
POST | customers.edit |
Admin resets customer portal password |
GET /sales/customers?paged=1 — one call
Takes from / to (YYYY-MM-DD, Indian days, both included) on when the customer was
added, and sort = createdAt | firstName | lastName with dir
(Dates and order).
Every row carries customerCode (CU-000123), and the search q matches it as it matches
the name, phone, email and passport (PTY-006). Both the one-call
form and the PostgREST fallback do.
A page of the list (paged=1 or a cursor) is one database call,
customers_list_page (PRF-010).
It reads as the caller: row security on Customer and portal_context() decide what comes
back, as they did for the two PostgREST reads and the portal_context call it replaces. A
partner login still gets its own agency only, with identity numbers masked. The answer is
the paging contract of paging, unchanged: same row shape, same
opaque cursor, total exact with withTotal=1, otherwise the planner's estimate on the
first page.
With options=1 the first page (no cursor) also carries the pickers the customers screen
uses, for staff only:
{ "data": [], "nextCursor": null, "total": 0, "totalIsEstimate": false,
"options": {
"agents": [{ "id": "…", "partnerCode": "BP-0012", "name": "…", "company": "…", "email": "…" }],
"currencies": [{ "code": "INR", "name": "Indian Rupee", "symbol": "₹" }],
"groups": [ /* active departures, GET /groups row shape */ ]
} }
agents are the live partners GET /agents lists (without its booking totals), groups
are the departures GET /groups lists by default, and currencies is what
GET /admin/currencies answers — null without admin.currency.view, where that route
answered 403. The unpaged form (no paged, no cursor) still uses the PostgREST reads,
and so does every page on a database without customers_list_page.
POST /sales/customers
Cite: src/lib/api.ts:7037.
Input — full personal profile: { title?, firstName, lastName?, dob?, gender?,
fatherName?, bloodGroup?, passportNo, passportIssuedDate?, passportExpiry?, nationality?,
panCard?, aadhaarNumber?, phone?, email?, address?, district?, state?, country?,
pinCode?, packagePrice?, currency?, roomType?, source?, sourceAgentId?, agentId?,
gstNumber? }.
Special rules:
- Names — a person needs a name, not two name parts (PAX-007).
lastNamemay be blank or omitted; a payload carrying only a surname stores it as the given name; a payload with neither returns400 A name is required. - Passport uniqueness — if
passportNomatches an existing row: - If the existing row is soft-deleted, it is revived (ID reused; fields updated).
- If active, returns
409 Conflict— "A customer already exists with this passport number." gstNumberis uppercased + trimmed defensively.sourcedefaults toDirect. Partners' clients setsource='Through Business Partner'withsourceAgentIdpointing to the partner.
GET /sales/customers/:id/ledger
Returns a customer's full ledger — every payment, invoice, booking commitment, on-account receipt allocation — with running balance per row and summary totals.
Per-customer passengers (family members)
Customers can register saved passengers (spouse, children) that get auto-populated when
booking. CRUD is at /sales/customers/:id/passengers/[:pid] — all writes require
customers.edit. Cite: src/lib/api.ts:7303.
Partners (agents)
Handlers: handleAgents at src/lib/api.ts:12646,
handleAgentsById at src/lib/api.ts:12801,
handleAgentLedger at src/lib/api.ts:7713.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/agents |
GET | (read-only) | List partners with partnerCode (BP-0012, PTY-001) and totalBilled / totalPaid / outstanding / totalBookings rollups, and city, state, tier, region, relationshipManagerId, relationshipManagerName, ownerName and primaryContactName from the partner profile and contacts (partner_list_extras, agents.view; null without it — PTR-090, PTR-092), and the agency's own contactPerson |
/agents |
POST | partners.create |
Create partner — always pending until approved (PTR-001) (the database gives the partner code); optionally provisions a Supabase auth user + AGENT role; auto-ensures A/R + A/P GL accounts, named with the partner code (PTY-004) |
/agents/:id |
PATCH | partners.edit |
Update partner; optionally createLogin=true provisions login; supports re-linking to existing auth user |
/agents/:id/status |
POST | partners.approve to decide a pending partner or make active one never approved; partners.edit otherwise |
Body { status, reason }. Calls partner_set_status, which checks the same rule, records the reason, tells the partner and e-mails sales@ on an approval decision (PTR-095, PTR-096). Returns { id, status, from, unchanged }. A browser PATCH that changes the status is held to the same rule by the trigger Agent_approval_guard |
/agents/:id |
DELETE | partners.delete |
Soft-delete; rejected (409) if partner has bookings or ledger entries |
/agents/:id/ledger |
GET | (read-only) | Agent ledger with running balance |
/partners/:id/record |
GET | agents.view |
The partner record — partner_record (PTR-083); shape in Admin → Agents |
/partners/:id/documents/:docId/review |
POST | partners.edit |
Review a registration document — partner_document_review (PTR-080) |
/partners/:id/documents/:docId |
DELETE | partners.edit |
Remove a document record with a reason — partner_remove_document (PTR-087) |
/partners/:id/profile |
PATCH | partners.edit |
Change the partner profile — partner_update_profile_admin (PTR-084); shape in Admin → Agents |
/partners/:id/contacts, /partners/:id/contacts/:contactId |
POST, DELETE | partners.edit |
Add / change, remove a contact — partner_contact_save, partner_contact_remove (PTR-085) |
/partners/:id/notes |
POST | partners.edit |
Add a note (append-only) — partner_note_add (PTR-088) |
POST /agents
Cite: src/lib/api.ts:12704.
Input — { name, company?, email?, phone?, commissionRate?, creditLimit?, panCard?, gstNumber?,
address?, contactPerson?, createUser?, password? }.
A non-zero commissionRate needs agents.commission.set, and a non-zero creditLimit
agents.credit_limit.set — finance and leadership only. The database refuses the insert
otherwise ("Only finance or leadership can set a partner's …", trigger Agent_terms_guard,
PTR-030); PATCH /agents/:id is held to the same rule for a changed value.
Side-effects:
- If
createUser=true, callinvokeAdminUsers({ action: 'create_user', ... })to provision the auth user, assign AGENT role, and linkAgent.userId. - If the auth user already exists (email collision), link the existing user and reset the password.
- Ensure the partner's receivable (A/R) and payable (A/P) sub-ledger accounts exist
in the chart of accounts (
ensureAgentReceivableAccount,ensureAgentPayableAccount).
Returns — { id, name, tempPassword?, loginError? }.
DELETE /agents/:id
Delete is blocked if history exists
Returns 409 Conflict with a clear message if the partner has any:
- Bookings (
Booking.agentId = :id) - Ledger entries on their A/R or A/P GL accounts
Deactivate via PATCH /agents/:id with status='inactive' instead. Cite:
src/lib/api.ts:12807.
Suppliers
Handler: handleSuppliers at src/lib/api.ts:17065.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/suppliers |
GET | (read-only) | List (supports ?category=, ?includeInactive=true); each row carries supplierCode (SU-0005, PTY-001), as does GET /suppliers/:id |
/suppliers |
POST | suppliers.create |
Create supplier — pending until approved (PTY-009); duplicate-detects by category + name (+ airline for ticketing). Rows carry approvalStatus (pending / approved / rejected) and approvalNote |
/suppliers/:id/decision |
POST | suppliers.approve |
Body { decision: 'approve' \| 'reject', note }; a rejection needs a note. Calls supplier_decide; a repeat of the same decision is a no-op. Returns { id, approvalStatus, from, unchanged } (PTY-009) |
/suppliers/:id |
PATCH | suppliers.edit |
Update supplier |
/suppliers/:id |
DELETE | suppliers.delete |
Soft-delete |
/suppliers/:id/ledger |
GET | (read-only) | Supplier ledger with running balance; filters SupplierTransaction rows whose journalEntryId is not yet approved |
/suppliers/:id/transactions |
GET | (read-only) | All transactions |
/suppliers/:id/transactions |
POST | suppliers.edit (+ groups.edit with groupId) |
Create supplier transaction (debit / credit / refund) — refused while the supplier is not approved (PTY-009); posts journal; optionally deducts TDS. With groupId, the bill is that departure's cost (FIN-041) |
/suppliers/transactions/:txnId |
PATCH | suppliers.edit |
Edit transaction; auto-reposts journal delta |
/suppliers/transactions/:txnId |
DELETE | suppliers.delete |
Delete transaction; reverses journal |
/suppliers/transactions/:txnId/allocate |
POST | suppliers.edit |
Apply a credit/refund to a specific debit |
/suppliers/quota-payments |
GET | inventory.view |
Supplier payments tied to airline quota blocks |
A bill against a departure
POST /suppliers/:id/transactions takes groupId on a debit, plus optional
expenseDescription, expenseCategory and expenseNotes. The bill is
recorded and posted as always, and the departure's cost row is written in the
same call, linked to it (GroupExpense.supplierTransactionId). That row
posts nothing — the bill already did — and the bill's voucher is tagged to the
departure, so the ledger and the group cost report hold the cost once.
The caller needs groups.edit as well as suppliers.edit; the database checks
both. Recording the same cost a second time under Groups → Expenses, without
naming the bill, is refused with a message naming the bill to use
(FIN-041).
Duplicate detection
POST /suppliers first calls findSupplierDuplicate which checks:
- For
category='ticketing'with anairlineId: any existing active supplier for that airline. - For all categories: any existing supplier with the same category + normalized name (case-insensitive, whitespace-collapsed).
If a duplicate is found it is returned as 409 Conflict with the existing row's id.
TDS inside supplier flows
Cite: src/lib/api.ts:17650. When a supplier payment qualifies
for TDS deduction, the handler calls requirePermission('finance.tds.deduct') inline
before recording the deduction and TDS-liability journal.
Airline quota block outstanding guard
Cite: src/lib/api.ts:17200. When creating a supplier credit/refund
linked to an AirlineQuotaBlock, the handler computes outstanding = (seats × pricePerSeat)
- already-paid and rejects (400) if the new payment would exceed the outstanding amount.
Leads
Handlers: handleLeads at src/lib/api.ts:20023,
handleLeadById at src/lib/api.ts:20076,
handleLeadConversion at src/lib/api.ts:19897.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/leads or /sales/leads |
GET | (read-only) | List leads |
/leads or /sales/leads |
POST | leads.edit |
Create lead |
/leads/:id |
GET | (read-only) | One lead |
/leads/:id |
PATCH, DELETE | leads.edit |
Update / delete |
/leads/:id/convert |
POST | leads.edit |
Convert lead → customer (+ optional booking) |
POST /leads/:id/convert accepts { createBooking?: boolean, bookingPayload? } and when
createBooking=true delegates to handleSalesBookings('POST', ...).
GET /leads takes status, source, travelType, groupId, from / to (YYYY-MM-DD,
Indian days, both included, on when the lead was received) and sort = createdAt |
updatedAt | fullName | status (Dates and order).
GET /leads — paged or not, including ?id= — is one database call, leads_list_page
(PRF-010), read as the caller
through the Lead and User row security. It returns the same rows with lostByName
filled, where it used to read the page, count it and then look the names up. With
options=1 the first page also carries options.groups, the active departures in the
GET /groups row shape (the convert dialog's picker). On a database without the function
the route falls back to those reads.
Customer 360
GET /journey/customers/:id is one database call, customer_360_screen
(PRF-010). It checks
customers.view (403 without it, as before) and returns get_customer_360's answer with
tabs from get_customer_360_tab_meta, as before, plus timeline: the first 100 entries
of GET /timeline/customer/:id, in that route's shape. The activity card uses it instead
of asking again; older entries still come from GET /timeline/customer/:id?before=. On a
database without the function the route falls back to the two calls.
GET /journey/customers/:id/files (customers.view) — every file of the customer for the
360's Files card (C360-001,
ACC-074):
{ customerDocuments: [{ id, docType, fileName, mimeType, driveViewLink, createdAt }],
visaDocuments: [{ id, visaCaseId, fileType, fileName, mimeType, createdAt, bookingNo }],
issuedDocuments: [{ id, bookingId, kind, label, fileName, issuedAt, bookingNo }] } — the
customer's documents, the documents of their visa cases (not deleted) and the live issued
e-tickets and vouchers of their bookings. Each part is read under its own row security; a
part the viewer may not read is empty. Each file opens through drive-file.
POST /sales/customers/:id/documents/:docId/review
COMM-035. { decision: 'ok' | 'rejected', note? }
— the office accepts or rejects a customer document (the ones sent on WhatsApp wait for this).
customers.edit, checked again in customer_document_review(), which refuses a paused login
and a rejection without a note. Answers { id, reviewDecision, reviewNote, reviewedBy,
reviewedAt }. A trigger refuses any other write of a document's source or review.
GET /journey/customers/:id/files returns, for each customer document, also source
(whatsapp or null), docPart (passport_front, passport_back, photo,
payment_proof), passengerId, bookingId, travellerName, reviewDecision,
reviewNote, reviewedAt.
Files on the company drive
Every file lives on the company Shared Drive Alhuda-Shared-Drive, shared with nobody, and
never in Supabase Storage (ACC-073, ACC-074).
The browser and the app store and open files only through these edge functions.
| Function | Method | Who | What |
|---|---|---|---|
drive-upload |
POST { kind, refId, fileName, mimeType, fileData } (base64) |
per kind: group_doc groups.edit, voucher_attachment finance.edit, tds_certificate finance.tds.deduct, incident_doc incidents.manage, visa_doc visa.edit, ticket_doc tickets.edit (the record refId must exist); customer_request, payment_receipt: a customer for their own record |
Stores the file under the kind's folder, records it in DriveFile, answers { success, file: { driveFileId, ref: 'drive:<id>', fileName, mimeType, sizeBytes } }. The screen then saves ref on its record. A PDF or a photo, 10 MB at most; 400 otherwise, 403 without the permission or for a paused login, 404 unknown record, 503 the drive not connected. |
drive-file |
POST { kind, id } |
per kind, below | Answers { url, expiresIn: 600, fileName, mimeType }: url is drive-file?t=<signed token>, which streams the file (inline for a PDF or an image, else a download) until it expires. 404 when the file is not the caller's to see; 409 when the record has no file. |
drive-file |
GET ?t=<token> |
whoever holds an unexpired link | The bytes. 403 an expired or altered link. Deployed with verify_jwt = false. |
upload-customer-doc |
POST | as in the table above | A customer's document. |
upload-partner-doc |
POST | the partner's own login | A partner's registration document (partner_add_document). |
upload-employee-doc |
POST { userId?, docType, fileName, mimeType, fileData } |
the person; admin.users.edit with userId |
An employee's document (employee_add_document). |
chat-upload |
POST multipart { file } |
a staff login | A chat attachment; answers { provider: 'drive', storageKey: 'drive:<id>', … }. |
drive-file kinds: customer_file (the traveller's own, or customers.view), visa_file
(own, or visa.view), ticket / hotel (own row, or bookings.view; opens the live issued
PDF), issued, partner_file, employee_file (read as the caller — the row's own security
decides), chat_file (a staff login in the message's channel or conversation) and file (a
DriveFile id: its uploader, or staff with the view permission of the kind the server
recorded — groups.view, finance.view, finance.tds.view, incidents.view, visa.view,
tickets.view, requests.view, leads.view, visa.intake.view).
The website's forms send their files inside the form: lead-intake takes
files: [{ fileName, mimeType, label, data }] (10 files, 10 MB each, 20 MB in all) and
visa-intake the same (5 files) — both only after the captcha; visa-intake's
upload-url action is retired (410).
People (unified party query)
The "People" frontend page reads from /sales/customers for customers and /agents for
partners — there is no /people handler. Dedicated to the People UI:
GET /sales/customersfor customer list.GET /agentsfor partner list.GET /usersfor staff users (see Admin).
Customer portal password management
POST /sales/customers/:id/password — creates or resets the customer portal login. Cite:
src/lib/api.ts:8014. Supports two modes depending on body:
{ action: 'create', password }— provisions a Supabase auth user for the customer (linked viaCustomer.userId) and assigns theCUSTOMERrole.{ action: 'reset', password }— resets the existing linked user's password.