09 · Audit trail and data protection
AUD-001 · Every change is recorded
Status: LAW · Source: Companies (Accounts) Rules 2014 r.3(1) as amended (in force 1 April 2023)
Every change to business and accounting records is recorded with the original value, the new value, who made it and when. The record is written by the database, so it cannot be skipped by any screen or API path.
Enforced by: 20260917120000_audit_trail_hardening.sql (branch feat/audit-trail), test supabase/tests/audit_trail.sql.
Telling people things
REQ-001 · A refusal says why, to the person who asked
Status: DECIDED (2026-09-23) · Owner: Management When a customer's or a partner's request is approved, rejected or closed, the decision is shown to the person who raised it. The fuller internal note stays staff-only.
The database already required staff to write a note to refuse a request, and both portal reads dropped it — so the person who asked saw the word "rejected" and nothing else.
Enforced by: 20260926180000_a_refused_request_says_why.sql — CustomerRequest.decisionReason is written by update_customer_request and returned by both portal reads. CustomerRequestNote is untouched and stays staff-only.
REQ-002 · An internal reason is not sent to a customer
Status: DECIDED (2026-09-23) · Owner: Management A reason written for a colleague — "Passport unreadable", "Price below cost" — is never put in front of a traveller or a partner. Booking status emails say what has happened in words the recipient can act on, and carry no internal note and no internal status token.
Before this, refusing or sending back a booking emailed the customer the raw note and the raw status: "To: NEEDS_CORRECTION / Notes: Passport unreadable".
Enforced by: src/lib/api.ts (the three status-change mails no longer pass notes) and supabase/functions/mailer/index.ts (booking_status_change says the status in plain words and never prints a note). The visa issued / refused e-mail is built in the database without the staff reason (visa_status_notify, VISA-005).
AUD-002 · The audit trail cannot be switched off or altered
Status: LAW · Source: as AUD-001 No user or role — including administrators — can edit, delete or wipe audit records. If an audit record cannot be written, the business change is rejected. Enforced by: append-only triggers and revoked privileges (same migration).
AUD-003 · Retention: 8 years
Status: LAW · Source: Companies Act 2013 s.128(5) Books of account and their audit trail are kept for at least 8 financial years. No automated purge may remove them.
AUD-004 · Who can read the audit trail
Status: PROPOSED · Owner: Management
The full audit trail is readable by users with audit-view permission (Auditor, leadership). Staff who can view a booking, customer or group can see that record's activity timeline.
The same holds for a partner record (agents.view) and an employee record (admin.users.view), decided 2026-09-25 with PTR-083 and ACC-071: the timeline shows the audit rows on that agency or person and by their login — the action, the changed field names and the actor — never the row's old and new values, which stay behind admin.audit.view.
Enforced by (records): partner_record(), employee_record() (20260929230000_a_record_links_everything.sql); test supabase/tests/a_record_links_everything.sql.
AUD-010 · Drift is reported, repairs are approved
Status: DECIDED 2026-09-17 · Owner: Management
A nightly check compares modules (group counts vs passengers, seats vs allocations, payments vs ledger, visa cases vs passengers). Differences are reported; corrections are applied only after management approval and are themselves recorded.
Enforced by (partial — inventory only): 20260920100200_inventory_drift_check.sql. inventory_drift_report() lists every inventory counter that disagrees with the allocation records — airline block and FIT seats, hotel rooms and beds, meal-days, vehicle capacity — with the stored value, the derived value, the difference and a severity (over_allocated, oversold_risk, drift, advisory). It is read-only and needs only inventory.view. Applying a correction is a separate call, inventory_repair_drift(), which needs inventory.counters.repair, refuses to run without a written reason, and writes one InventoryDriftRepair row per counter changed recording what it said before, what it was changed to, who did it and why. Rows that are genuinely over-allocated come back under failed rather than being "fixed" — no counter edit can conjure a seat. Advisory rows (for example HotelInventory.bedsTotal not matching totalRooms × avgBeds) are reported and never repaired automatically, because only an operator knows which number is the contract.
Nothing schedules this yet (pg_cron runs only the communications dispatcher, LC-007, the WhatsApp template sync, AUD-024, and the leave accruals, LV-062), so "nightly" is a deployment concern; the function is the contract. Group counts vs passengers, payments vs ledger and visa cases vs passengers are not covered yet. Test: supabase/tests/inventory_integrity.sql.
Personal data
AUD-020 · Identity numbers are masked outside their record
Status: PROPOSED · Owner: IT · Source: Digital Personal Data Protection Act 2023 (reasonable security safeguards)
Passport, Aadhaar, PAN and bank account numbers appear in full only on the customer/passenger record for users allowed to see it. Audit records, logs, exports and notifications show only the last 4 characters.
Enforced by (partial): audit masking (20260917120000); ticket and request lists mask passport numbers (maskIdentityNumber in api.ts); visa-intake staff email masks the passport (20260918130000 wave). Remaining list/export masking is Wave 1A.
AUD-021 · Passport scans are private files
Status: PROPOSED · Owner: IT
Passport scans and other identity documents are downloadable only by staff with access to that customer, or by the customer themselves, through short-lived signed links checked per file.
Enforced by: every such file is on the company Shared Drive, shared with nobody, and is opened only through drive-file, which checks the viewer against that record's own permission and hands out a ten-minute link (ACC-073, ACC-074). No record keeps a URL to a file: a lead's attachments, a voucher attachment, a TDS certificate, a group document and an incident document keep the Drive reference (drive:<id>); 20260926190000_a_lead_keeps_the_names_it_collected.sql refuses a public URL on a lead attachment. (Until 27 Sep 2026 incident documents and lead attachments sat in private buckets with two-minute signed links, and group documents, voucher attachments and TDS certificates stored getPublicUrl() links that never opened.)
Related (integration secrets): Resend / Mailjet / WhatsApp / MSG91 secrets live in IntegrationSecret (service role only) or function env, written through set_integration_secret; staff see configured flags only; WhatsApp sends, integration tests and the WhatsApp template sync run in edge functions whatsapp-send / integration-test / whatsapp-templates-sync (20260918130000_tickets_visa_comms.sql, 20261003110000_whatsapp_templates_come_from_meta.sql) — the Meta token is read there and sent to Meta in a header, never in a URL or to the browser.
AUD-022 · Purpose and consent
Status: OPEN · Owner: Management · Source: DPDP Act 2023 and DPDP Rules 2025 (obligations phasing in)
Decide the privacy notice, consent capture at enquiry/booking, the retention period for personal data not needed as books of account, and how customers request access, correction or erasure.
Erasure of a traveller's or partner's own account is decided and built (2026-09-30; since 2026-10-01 always by a request the office approves): AUD-025 … AUD-027. Erasure for someone without an account, access and correction requests, and the retention period remain open.
Interim (built, pending decision): CustomerContactPreference per channel; transactional messages are sent unless the customer opted out, marketing/broadcast needs an explicit opt-in (customer_contact_allowed); WhatsApp "STOP"/"START" replies update it; bulk sends show a consent summary and send only to allowed recipients. Change the default in customer_contact_allowed once management decides.
AUD-023 · Breach response
Status: OPEN · Owner: Management · Source: DPDP Rules 2025 Name who is responsible for detecting and reporting a personal-data breach to the Data Protection Board and affected customers, and the timeline.
AUD-024 · WhatsApp templates are copied from Meta
Status: PROPOSED · Owner: IT · Source: Meta WhatsApp Business Platform policy (a business-initiated message outside the 24-hour customer-care window must be an approved template); AUD-021
The templates staff may send outside the 24-hour window are the ones Meta holds for the company's WhatsApp account, copied in — not typed. One row per template name and language, sent in exactly the language Meta holds it in. A row is offered to staff only when Meta has approved it, it is not an authentication (one-time code) template, and it needs nothing but body text; every other template is kept and listed with Meta's status and the reason. A template gone from Meta is made unusable, never deleted, because messages name it. Amended 30 Sep 2026 (owner, COMM-021): a template whose only extra part is a Document header (no variable in it) is usable for the automatic booking notices — the notice attaches the PDF itself — but is still not offered in chat; a chat-usable template is usable for the notices too. The copy is made by an administrator (admin.integrations.edit) or the nightly job, with the Meta token read server-side only (AUD-021), and every run is audited with who, when and the counts.
Enforced by: edge function whatsapp-templates-sync (permission, staff only, not paused; audit row whatsapp_templates.sync / whatsapp_templates.sync_failed); 20261003110000_whatsapp_templates_come_from_meta.sql — unique (name, language), and a row can be active only if its status is APPROVED (or it was typed by hand before the sync) and its category is not authentication; row security lets every staff login read the table but only admin.integrations.edit change a row by hand (policies "WhatsAppTemplate staff read" and "WhatsAppTemplate integrations edit" — was any staff login); the pg_cron job alhuda-whatsapp-templates-sync (21:30 UTC) through the dispatcher's Vault secrets; whatsapp-send picks the row by name and language and names each parameter of a named template. The notice column usableForNotices (with headerFormat) is written by the same sync (usableForNotices() in _shared/whatsappTemplates.ts) and can be true only for an approved, non-authentication template (20261003234500_whatsapp_booking_notices.sql). Tests: supabase/tests/whatsapp_booking_notices.sql, src/services/whatsappBookingNotice.test.ts, supabase/tests/whatsapp_templates_come_from_meta.sql, src/services/whatsappTemplateSync.test.ts, src/services/whatsappTemplateAdmin.test.ts, src/components/admin/WhatsAppTemplatesCard.test.tsx.
Not built: creating, editing or submitting a template from the ERP (it is done in Meta); sending a template whose header takes a picture, a video or a variable, or whose link button takes a value; sending a document-header template from chat.
AUD-025 · A traveller or a partner asks for their account to be deleted
Status: DECIDED 2026-10-01 (amends the decision of 2026-09-30) · Owner: Management · Source: owner, 2026-10-01 — "we can't let customers delete accounts on their own especially B2b and Employees, and for customers, if there is a balance on their accounts, we can't let them delete accounts on their own" and "does banking apps offer account deletion? No, so why is mandatory for us, keep it only for direct customers and that too upon approval"; Apple App Store Review Guideline 5.1.1(v) and Google Play's account-deletion policy (both accept an in-app deletion request that the business completes after review); Digital Personal Data Protection Act 2023 s.12 (the right to erasure)
Nobody erases their own account. Anyone who holds a traveller (CUSTOMER) or partner (AGENT) login may ask for it to be deleted: in the app (Me → Request account deletion; partner: More → Request account deletion) and on the website (customer portal → My Profile; partner /me). The screen says what is erased, what the law keeps (AUD-026) and what happens next; the person types DELETE and their password. That always files a request — even when nothing is open — and the person reads: "Your request has been sent. The office will review your account and contact you." Nothing is erased, the person stays signed in, and a second tap finds the same request. The office reviews it and approves (erases) or declines within 30 days of the request (AUD-027). Every step is audited (account_deletion_requested, account_deleted, account_deletion_declined), with the party code and never the name.
A business partner's request carries the line "Business partner — the office decides": the office reviews the agency (bookings, invoices, commission, the ledger) before anything is erased. Whether partners keep this request (as "Request account closure") or lose it together with in-app partner sign-up is the owner's open choice; it is request-only either way.
A staff login is not offered this and is refused if it tries: leaving the company is HR's offboarding (ACC-071). A tour leader who is a partner (PTR-004) is a partner here.
Enforced by: 20261005090000_an_account_is_deleted_only_on_request.sql — account_delete_self() runs for the service role only, refuses a staff login and anything but the word DELETE, locks the login's row, and never erases: it files one pending request per login with the reasons (account_deletion_reasons(): the review line office_review or partner_review, then the blockers). account_deletion_status() is the person's own view (byRequestOnly always true). The register AccountDeletion (from 20261004090000_a_person_deletes_their_account.sql) is read by the person and by the office and written only by the functions. The edge function account-delete checks the session and the password (a sign-in, throttled like sign-in — AuthLoginAttempt action account_delete), then calls the database and words the answer. Tests: supabase/tests/a_person_deletes_their_account.sql, supabase/functions/account-delete/logic.test.ts, apps/mobile/src/lib/accountDeletion.test.ts, src/components/account/DeleteMyAccountCard.test.tsx.
Not enforced by the database: the 30-day answer. The office's list shows an answer by date for each request; nothing escalates when it passes.
AUD-026 · What deletion erases and what the law keeps
Status: DECIDED 2026-09-30 · Owner: Management · Source: DPDP Act 2023 s.8(7) and s.12(3) (erase unless retention is necessary for compliance with law); Companies Act 2013 s.128(5) (books of account, 8 years); CGST Act 2017 s.35–36 and CGST Rules r.46, r.56 (tax invoices and accounts); Income-tax Act 1961 s.206C(1G) (TCS on overseas tour packages — the buyer's PAN); AUD-002, AUD-003
Erased. The login: name → "Deleted user", email → deleted+<id>@deleted.invalid, phone, username, two-step and finance-PIN secrets; it is inactive and marked deleted; its sessions, push tokens (push_subscriptions), location sharing and positions, in-app notifications, backup codes and reset codes are removed. The traveller's customer record: name → "Deleted user", title, passport number (replaced by DELETED-<id>), passport dates, nationality, phone, email, address, district, PIN, date of birth, gender, father's name, blood group, Aadhaar number, remarks; saved family members; the document store (CustomerDocument — passport scans, photos, Aadhaar, PAN) with its Drive files moved to the trash; contact preferences. On the person's own bookings (made or paid by them) and their own place on anyone's booking, each traveller's passport number and dates, date of birth, phone, email and mother's name, and the same on their visa cases; the bookings' emergency contacts. The WhatsApp contact's number, WhatsApp id and name (the conversation stays, addressed to nobody). For a partner: the agency's contact person, email and phone, the owner's name on the profile, the agency's contact people, and the identity and bank papers (Aadhaar, PAN card scan, address proof, cancelled cheque, other); the agency becomes inactive.
Kept, and why. Bookings (numbers, dates, services, amounts), payments, receipts, invoices, credit notes, refunds, tickets and the ledger — books of account and tax records, kept 8 years (AUD-003). On the customer record: the customer code (PTY-001), PAN and GSTIN (printed on tax invoices; PAN is required for TCS returns), state and country (the place of supply on an invoice). Travellers' names on bookings, tickets and visas — the name is on the issued ticket, visa and invoice. For a partner, the business record: trading name, company, PAN, GSTIN, business address, commission, credit limit, partner code, the proof of business, the GST certificate and the signed agreement — the agency is a party to invoices, commission TDS and the ledger. Travellers a partner booked are the company's own customers with their own records; a partner's deletion does not erase them (each may ask). The audit trail keeps what it recorded, with identity numbers masked (AUD-020); it cannot be edited (AUD-002).
Not built: erasing the kept records when their retention period ends — AUD-022 (the retention period for personal data outside the books of account) is still open.
Enforced by: account_anonymise() (internal; since 2026-10-01 called only by account_deletion_complete — the office's approval). Test: supabase/tests/a_person_deletes_their_account.sql.
AUD-027 · The office settles what is in the way first
Status: DECIDED 2026-10-01 (amends the decision of 2026-09-30) · Owner: Management · Source: AUD-025; owner, 2026-10-01 (a balance either way stops a deletion); REQ-001, REQ-002 Every deletion is a request the office decides (AUD-025), and the office cannot erase an account while it would break a live obligation or leave money unsettled:
- a trip that has not finished (a booking not cancelled, refused or transferred whose departure has not returned — or, with no departure, still being processed);
- a balance still to be paid on a booking, or an unpaid group invoice addressed to the person or the agency;
- money the company holds for the person: a booking paid beyond what it now bills — an overpayment, or a cancelled traveller's refund not yet paid out (
fin_refundable, PRC-030); a refund asked for and not yet approved (approval pays it out, so there is no "approved but unpaid" refund); a group invoice paid beyond its total less its credit notes; an advance received on account not yet allocated. The person reads it in plain words — "We hold ₹5000.00 for you on booking BK-4 — the office will settle it with you first." There is no customer wallet; - a payment the person reported that finance has not checked — against a booking, or (a partner) against a group invoice, named by its number — a request the office has not closed, a departure the person leads that has not returned, or an account the office paused (ACC-070).
The person sees the list when they ask; the request is recorded once (one pending request per login). The office settles each item the usual way (cancel the trip at the person's wish, record the payment, pay the refund, allocate or return the advance, close the request), then approves — Complete deletion, only once nothing is in the way — or declines with a note, which the person reads (REQ-001) and may ask again. The office answers within 30 days of the request. Travellers' requests are handled with customers.edit, partners' with partners.edit; no new permission.
Enforced by: account_deletion_blockers() (20261005090000_an_account_is_deleted_only_on_request.sql; re-created by 20261006140000_invoices_airline_refunds_deletion.sql: a partner's pending claim on a group invoice — a Payment with no booking and the invoice set — is a payment_claim line naming the invoice, and the balance_due / invoice_due lines read money through account_deletion_money, ₹ for rupees; test supabase/tests/invoices_airline_refunds_deletion.sql), account_deletion_requests() (the office's list, per kind, with answerBy = asked + 30 days), account_deletion_complete() (service role only: the account-delete function passes the verified staff id and the database checks its permission; refuses while any blocker is open — the review line is not a blocker), account_deletion_decline() (a note is required), RLS "AccountDeletion own or office" (20261004090000_a_person_deletes_their_account.sql). Screen: /people/account-deletions. Runbook: Account deletion. Test: supabase/tests/a_person_deletes_their_account.sql.