Skip to content

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). lastName may be blank or omitted; a payload carrying only a surname stores it as the given name; a payload with neither returns 400 A name is required.
  • Passport uniqueness — if passportNo matches 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."
  • gstNumber is uppercased + trimmed defensively.
  • source defaults to Direct. Partners' clients set source='Through Business Partner' with sourceAgentId pointing 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, call invokeAdminUsers({ action: 'create_user', ... }) to provision the auth user, assign AGENT role, and link Agent.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:

  1. For category='ticketing' with an airlineId: any existing active supplier for that airline.
  2. 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/customers for customer list.
  • GET /agents for partner list.
  • GET /users for 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 via Customer.userId) and assigns the CUSTOMER role.
  • { action: 'reset', password } — resets the existing linked user's password.