21 · Parties and their codes
A party is anyone the business deals with by name: a customer (a traveller), a business partner, an employee, a supplier. This file holds the rules that apply to all of them alike. What is particular to each stays in its own file: customers in 01 · Customer lifecycle and 12 · Customer 360, partners in 11 · Partners, employees in 08 · Access control (ACC-071), suppliers in 07 · Finance.
Migration: 20261001100000_every_party_has_a_code.sql. Test: supabase/tests/every_party_has_a_code.sql; the booking import: src/services/bookingImportService.test.ts.
PTY-001 · Every party has a permanent code
Status: DECIDED 2026-09-27 (owner) · Owner: Management · Source: the owner, "reliability first" Every party has one human-readable code, given by the system, never typed by a person:
| Party | Code | Example |
|---|---|---|
| Customer / traveller | CU- and six digits |
CU-000123 |
| Business partner | BP- and four digits |
BP-0012 |
| Employee | EM- and four digits |
EM-0007 |
| Supplier | SU- and four digits |
SU-0005 |
The code is given when the record is made, in the order records are made. When a kind passes its width the number grows (BP-9999 is followed by BP-10000); it is never cut. Two parties never share a code, and a code is never given again — not after the record is deleted either. Records that existed before 27 Sep 2026 were numbered oldest first. Booking numbers (BK-00001) and group codes are not party codes and did not change.
The owner's reason is reliability: a name can be spelt two ways and an email can change, a code cannot. Staff quote the code on the phone, a partner quotes it on a spreadsheet, and the booking import (PTY-005) finds the right record by it.
Enforced by: one sequence per kind (customer_code_seq, partner_code_seq, employee_code_seq, supplier_code_seq), drawn only by the BEFORE INSERT trigger party_code_guard() on Customer, Agent, EmployeeProfile and Supplier; party_code_format() pads to the width and never truncates; the columns are UNIQUE and NOT NULL. A record inserted with a code (a restore) keeps it only if it is of the system's form, and the sequence is moved past it. Test: every_party_has_a_code.sql.
PTY-002 · A code never changes
Status: DECIDED 2026-09-27 (owner) · Owner: Management
Once a party has its code, nobody changes or clears it — not an administrator, not a data fix from the browser. A change is refused with the message "Customer code CU-000123 is permanent and cannot be changed". The name, phone, email and every other detail can still be changed as before. The employee profile form shows the employee code but cannot edit it.
Enforced by: party_code_guard() (BEFORE UPDATE on the four tables) raises on any change to a code that is set. admin_update_employee_profile still accepts employeeCode in its patch, so an unchanged value saves and a changed one is refused by the trigger. Test: every_party_has_a_code.sql.
PTY-003 · Every staff login has an employee code
Status: DECIDED 2026-09-27 (owner) · Owner: Admin/HR · Source: ACC-071
Every staff login — a login with a role other than CUSTOMER, AGENT and TOUR_LEADER, and with neither portal role (a portal role wins, PTR-004) — has an employee profile, and so an employee code. The profile is created, empty, when the login is given its first staff role; the office fills it in as before. A partner's or a traveller's login that leads a departure is not an employee and gets no employee code. An employee code typed by hand before 27 Sep 2026 is kept exactly as it was; a profile without one was given the next EM- number, in the order the logins were made.
Enforced by: the AFTER INSERT trigger UserRole_staff_profile (party_staff_role_profile(), using party_is_staff_login()); the migration's backfill made the missing profiles of existing staff logins. Creating the row has no other effect: EmployeeProfile has no other trigger, and an empty profile reads as it did when there was none. Test: every_party_has_a_code.sql.
PTY-004 · Finance keeps its ledger codes; a party ledger's name shows the party code
Status: DECIDED 2026-09-27 (owner) · Owner: Finance · Source: FIN-032
The party code does not replace the ledger account codes. A customer's receivable stays CUS- and the first eight characters of the customer's internal id, a partner's AGR- / AGP-, a supplier's SUP- — byte for byte as before, because the browser and the posting functions work them out from the id. What changes is only the ledger's display name, which now ends with the party code: "Crescent Tours (BP-0012)", "Ahmad Khan (CU-000123)". So the ledger lists, the party ledger picker and the finance search find a party's ledgers by its code as well as by its name. An account's name is never used to find an account (every lookup is by code or id), which is why it can carry the code.
Enforced by: the BEFORE INSERT OR UPDATE trigger Account_party_name (party_account_name_guard() → party_ledger_name()) adds the code on every path that makes or renames a party ledger — the browser (ensureCustomerDebtorAccount, ensureAgentReceivableAccount, ensureAgentPayableAccount, ensureSupplierCreditorAccount) and the posting functions (fin_ensure_party_account). A name never carries two codes. Where two parties share the eight characters of a ledger code the name is left alone rather than guessed. The migration renamed the existing party ledgers. Test: every_party_has_a_code.sql.
Not built: storing each party's ledger account id on the party, so that postings stop working out ledger codes from the id. That is a deliberate, separate later change.
PTY-005 · The booking import finds partners and customers by their code
Status: DECIDED 2026-09-27 (owner) · Owner: Sales
The Excel booking import has a Partner code column (BP-0012) on the first row of a booking and an optional Customer code column (CU-000123) on each passenger's row.
- A partner code names the partner. The older
agentIdandagentEmailcolumns still work; when a row has a partner code and anagentIdoragentEmailnaming a different partner, the booking is refused rather than guessed. - A customer code books that existing customer; no new customer is made. A code that does not exist is an error on the row that names the code. A code whose customer holds a different passport number from the row is an error too.
- Without a customer code the import finds the customer by passport or makes a new one, as before.
The import reads only the codes the sheet names, under the same row-level security as the partners and customers lists.
Enforced by: validateAndBuildPayloads() in src/services/bookingImportService.ts (pure, tested) and lookupPartyCodes() in src/services/partyCodeService.ts; the import route POST /sales/bookings/import links a passenger that arrives with a customerId. Test: src/services/bookingImportService.test.ts.
PTY-006 · The code is shown wherever the party is
Status: DECIDED 2026-09-27 (owner) · Owner: Management
The code is shown next to the party's name wherever a party is the subject of a screen or a document, and a list of parties finds one by its exact code typed into its search box. See Party codes for the list of screens, documents and searches.
Enforced by: the read functions carry the code — customers_list_page (rows and search), get_customer_360, partner_record, profile_of / profile_me, partner_my_account, admin_users_list (rows and search), booking_screen, booking_list_page, finance_screen; the routes GET /agents, GET /users, GET /suppliers, GET /suppliers/:id, GET /sales/bookings/:id/invoice, GET /group-invoices/:id; the issue-document edge function (e-ticket and hotel voucher). The partners, suppliers and employees lists on the web search in the browser over rows that carry the code. Test: every_party_has_a_code.sql (search and records), partnersMath.test.ts, suppliersMath.test.ts, UserManagement.test.tsx, PartnerRecord.test.tsx, MyProfile.test.tsx.
Not built: finding a booking by its customer's or partner's code in the bookings list; the partner portal's invoice list and printout (no code is read there yet).
PTY-007 · Staff find any party from the phone
Status: PROPOSED 2026-09-27 · Owner: Management · Source: the owner, "give staff more options to work from phone"
A member of staff finds a booking, a customer, a partner or a group from one search box in the app, by name, phone, passport number, booking number, group code or party code. Each kind is searched only for a login that holds its view permission (bookings.view, customers.view, agents.view, groups.view), and each is the read its own list already makes, under the same row security; the box never finds more than the lists would. A short code is read as the stored one (cu123 is CU-000123, bp12 is BP-0012). With customers.view the app also lists every customer and opens one: their code, contact details, trips, money, the current trip's passports, visas and tickets, the people they travel with, and their files.
Enforced by: the read functions and row security the lists use — booking_page, customers_list_page, customer_360_screen (checks customers.view; get_customer_360 leaves out and names a section the login may not read), CustomerDocument and Agent and TravelGroup row security, and drive-file for a file (kind customer_file: customers.view). The app hides a section without its permission (apps/mobile/src/lib/find.ts, tested in find.test.ts and customers.test.ts).
Not built: suppliers and employees in the search box (their own lists search them); finding a booking by its customer's or partner's code (PTY-006).
PTY-008 · Call or WhatsApp a party from their record
Status: PROPOSED 2026-09-27 · Owner: Management · Source: the owner, "give staff more options to work from phone"
Wherever the staff app shows a customer, a traveller on a booking, or a partner with a phone number, it offers Call and WhatsApp. They open the phone's own dialer and WhatsApp; nothing is sent from the office's WhatsApp account and nothing is recorded. A ten-digit number is Indian and is dialled with +91; a number that carries its country code keeps it; a masked number offers neither.
Enforced by: nothing on the server — the number is one the login may already read. apps/mobile/src/lib/contact.ts (tested in contact.test.ts).
PTY-009 · A new supplier waits for approval
Status: DECIDED 2026-10-02 (owner: "Same goes for suppliers also. And they should be approved by either general managers, CEOs, or Admin only") · Owner: Management
Every new supplier is pending until a General Manager, a CEO or an Admin (IT Admin, Super Admin) approves it. A rejection needs a reason and switches the supplier off; a rejected supplier is switched back on only by approving it. While a supplier is pending or rejected, nobody records a bill or a payment against it by hand. It can still be picked for inventory, so operations do not stall; the money waits. Suppliers that existed before 02/10/2026 are approved.
Enforced by: Supplier.approvalStatus (default pending), the trigger Supplier_approval_guard (the browser cannot write the approval columns or switch a rejected supplier on), the trigger SupplierTransaction_needs_approval, supplier_decide (suppliers.approve: GM, CEO, IT_ADMIN, SUPER_ADMIN), POST /suppliers/:id/decision (20261008120000). Test: supabase/tests/new_partners_and_suppliers_wait_for_approval.sql.
Not built: postings made by the database itself for a pending supplier (a hotel lease, an airline block purchase) are not held back.
PTY-010 · Sales hear about every new supplier
Status: DECIDED 2026-10-02 (owner: "should trigger an email to sales@alhudatravels.in") · Owner: Sales Manager · Source: PTY-009, COMM-039
When a supplier is added, the database e-mails sales@alhudatravels.in — the supplier, its code, type, contact and who added it — and puts a notice in the inbox of everyone who can approve it. The decision e-mails sales@ again. Once per supplier and event; a failed e-mail never stops the supplier being added.
Enforced by: triggers Supplier_onboarding_notice and supplier_decide → onboarding_notify → notify_enqueue (category onboarding) (20261008120000).