23 · Communications: booking e-mails, reminders, WhatsApp notices and the WhatsApp menu
What an e-mail about a booking says, who gets it, and the reminder e-mails the system sends by itself. Consent, secrets and the WhatsApp window stay in 09 · Audit and data protection (AUD-021, AUD-022); a visa e-mail's trigger stays in 06 · Visa (VISA-005); a new booking's announcement stays in 01 · Customer lifecycle (LC-007).
The owner, 30 Sep 2026: "when an email is sent to anyone related to booking or any auto email which contains any details or any payments, it should include some other details also, for example Customer Name or Customer Names if there are multiple people in the booking, along with booking ID. reminder emails should also be there".
Migrations: 20261003220000_every_booking_email_names_the_booking.sql, 20261003220100_booking_reminder_emails.sql, 20261006130000_notifications_share_one_queue.sql (COMM-037 … COMM-039). Tests: supabase/tests/every_booking_email_names_the_booking.sql, supabase/tests/booking_reminder_emails.sql, supabase/tests/notifications_share_one_queue.sql, src/services/bookingEmailSummary.test.ts, src/components/admin/BookingRemindersCard.test.tsx. The list of every e-mail: Booking e-mails.
COMM-001 · Every booking e-mail names the booking and its people
Status: DECIDED 2026-09-30 (owner) · Owner: Management · Source: the owner, 30 Sep 2026 (above) Every e-mail the system sends about a booking — received, placed, status changed, cancelled, a payment received, a refund, a visa milestone, a message staff send from the booking, and every reminder (COMM-010) — carries the same booking block:
- the booking number (
BK-…), in the subject line as well as the body; - the lead customer (with their
CU-code) and, when someone else pays, the payer; - every traveller on the booking by name, the lead first. Only live travellers: a cancelled traveller is left out, and a traveller moved to another booking is no longer on this one. A booking closed by a transfer says which booking(s) it went to;
- the departure: group name and code, travel dates (DD/MM/YYYY), the package (trip type and room type);
- the partner (agency name and
BP-code) on a partner booking.
The subject reads what happened — booking number — people, for example "Payment received — BK-00123 — Irfan Ahmad Dar & 2 others" ("& 1 other" for two travellers; the name alone for one). The plain-text copy of the e-mail carries the same rows.
Who receives each e-mail does not change: this rule changes what the e-mail says, not who gets it.
Enforced by: the database function booking_email_summary(p_booking_id, p_payment_id) (service role only) is the one answer; the mailer edge function reads it for every booking e-mail type when the sender passes the booking id, and renders it with supabase/functions/_shared/bookingSummary.ts. A browser caller gets the block only for a booking its own session can read under row security. When the summary cannot be read the e-mail still goes, with what the sender passed. Tests: every_booking_email_names_the_booking.sql, bookingEmailSummary.test.ts.
COMM-002 · An e-mail about money says the money
Status: DECIDED 2026-09-30 (owner) · Owner: Finance · Source: the owner, 30 Sep 2026
An e-mail about money — a booking received or placed, a payment received, a refund, a payment reminder — also shows the booking total (with GST), what has been received, any cancellation credit, and the balance due, in the booking's currency. A payment receipt adds the payment itself: amount, date, method, reference and receipt number. The figures are the database's own (FIN-040: receipts, allocations and money carried with a transferred traveller count; a pending payment does not). Rupees are written ₹2,15,000.00; another currency by its code (SAR 2,500.00). A payment is shown only on the booking it belongs to.
Enforced by: booking_email_summary() (money from fin_booking_receipts_total and fin_booking_cancellation_credit; the payment only if it is on the booking or allocated to it). Tests: as COMM-001.
COMM-003 · What a booking e-mail never carries
Status: DECIDED 2026-09-30 (owner) · Owner: Management · Source: AUD-020 (identity numbers are masked outside their record), ACC-073
No booking e-mail carries a passport number, an Aadhaar or PAN number, a date of birth, anybody's phone number or e-mail address, or a link that opens a document without the existing signed-link mechanism (ACC-073, ACC-074). A documents reminder says what is missing ("Passport details"), never a number. An internal reason typed by staff is never sent (REQ-002).
Enforced by: booking_email_summary() and the reminder functions do not read those columns; the templates print only the summary's fields. Tests: every_booking_email_names_the_booking.sql, booking_reminder_emails.sql, bookingEmailSummary.test.ts.
COMM-010 · Reminder e-mails: four kinds
Status: DECIDED 2026-09-30 (owner) · Owner: Operations and Finance · Source: the owner, 30 Sep 2026 ("reminder emails should also be there") The system sends four kinds of reminder by itself, each with the booking block (COMM-001):
| Kind | When (defaults) | Says |
|---|---|---|
| Payment due | 7 days before the balance due date | the balance and the due date. The due date is the departure's balance due days before departure; with none, 30 days before departure |
| Payment overdue | the day after the due date, then every 7 days, at most 4 times, until departure | the balance and how late it is |
| Departure | 7 days and 1 day before departure | the booking, the departure, the guide's before you go steps for the trip type (TRV-002), and the balance if any |
| Documents | 45 days and 21 days before departure, only if something is missing | per traveller: no passport details, no passport expiry, a passport that runs out within 185 days of return (JRN-003), or a visa whose passport and photo have not been handed in |
Each "days before" occasion stays open for three days, so a missed daily run is caught up, but never into the next, nearer occasion and never on the day of departure. Only a confirmed booking (APPROVED, PARTIALLY_CANCELLED) whose departure is still ahead and that has at least one live traveller gets a reminder.
Enforced by: booking_reminders_run() (pg_cron job alhuda-booking-reminders, 09:00 IST daily), booking_reminder_occasion(), booking_reminder_documents(), booking_reminder_carry(); the mailer template booking_reminder. Test: booking_reminder_emails.sql.
COMM-011 · A reminder goes once
Status: DECIDED 2026-09-30 (owner) · Owner: Engineering
Each reminder goes once per booking, kind and date: a second run the same day, two runs at once, or a retry queues nothing more. Every reminder — sent or skipped — is logged with its outcome.
Enforced by: BookingReminderLog with a unique key (booking, kind, reminder date), claimed before the e-mail is queued; an advisory lock on the run. Test: booking_reminder_emails.sql.
COMM-012 · Who receives a reminder
Status: DECIDED 2026-09-30 (owner) · Owner: Management
A reminder goes to the people who already receive the booking's e-mails, nobody new: on a partner booking, the partner (the agency's e-mail) — never the partner's client; on a direct booking, the customer — for the two payment reminders, the payer when someone else pays. A customer who opted out of e-mail (AUD-022) gets nothing; a customer or partner with no e-mail gets nothing. Both are logged as skipped.
The payment receipt e-mail follows the same rule (COMM-038, amended 02/10/2026): before, the browser sent it to the booking's customer even on a partner booking.
Enforced by: booking_reminder_queue() (customer_contact_allowed(…, 'email', 'transactional')); booking_notice_recipient() gives the same answer for the receipt e-mail (COMM-038); the mailer checks consent again when it sends. Tests: booking_reminder_emails.sql, notifications_share_one_queue.sql.
COMM-013 · A reminder stops when its reason goes
Status: DECIDED 2026-09-30 (owner) · Owner: Management
A paid balance stops the payment reminders; a passport recorded or a visa under way stops the documents reminder; a booking cancelled, rejected, transferred or departed gets no more reminders. If the reason goes between queueing and sending, the e-mail is not sent (the queue row is closed as cancelled with the reason).
Enforced by: booking_reminders_run() re-reads the facts every day; the mailer drops a booking_reminder whose booking is no longer confirmed or whose balance is paid (reminderNoLongerApplies), and the communications-dispatcher marks the row cancelled. Tests: booking_reminder_emails.sql, bookingEmailSummary.test.ts.
COMM-014 · Reminders are switched on by an administrator
Status: DECIDED 2026-09-30 (owner) · Owner: Admin
Reminders ship switched off: nothing is sent until an administrator turns on Send reminder e-mails in Admin → Reminders, after a confirmation that says who will be e-mailed. The same screen switches each kind on or off, sets the days (within bounds: 1–90 days before a due date, 0–365 for the default due date, every 1–60 days and at most 1–20 times for overdue, up to five occasions of 1–120 days for departure and 1–180 for documents) and lists the last 50 reminders with their result. Reading needs admin.integrations.view; changing needs admin.integrations.edit, and a paused login cannot change it. Every change is audited.
Enforced by: BookingReminderSetting (one row, table checks on every number, no browser access), booking_reminder_settings() and booking_reminder_settings_save(); audit reminder_settings_changed. Tests: booking_reminder_emails.sql, BookingRemindersCard.test.tsx.
Not built: reminders by WhatsApp (each needs a Meta-approved template; only the two notices below are sent on WhatsApp); a quotation expiring reminder for leads; a reminder per instalment (bookings have one balance due date, not a schedule of instalments — see PRC-010); reminders to staff about bookings (the work inbox does that); a per-booking "do not remind" switch other than the customer's e-mail consent.
WhatsApp notices
The owner, 30 Sep 2026: "there should be auto whatsapp message for payments and booking confirmation, which if possible should send PDF invoices or receipts or other details also".
Migration: 20261003234500_whatsapp_booking_notices.sql. Edge function: whatsapp-booking-notice. Tests: supabase/tests/whatsapp_booking_notices.sql, supabase/functions/whatsapp-booking-notice/index.test.ts, src/services/whatsappBookingNotice.test.ts, src/components/admin/WhatsAppNoticesCard.test.tsx, src/components/admin/WhatsAppDeliveryProblems.test.tsx. What each notice says: Booking WhatsApp notices. Setting it up in Meta: WhatsApp Cloud API.
COMM-020 · Two automatic WhatsApp notices
Status: DECIDED 2026-09-30 (owner) · Owner: Operations and Finance · Source: the owner, 30 Sep 2026 (above) The system sends two WhatsApp messages by itself:
| Notice | Sent when | Says |
|---|---|---|
| Booking confirmed | a booking becomes APPROVED — finance approves it, or its status is set to approved on the booking edit route. The same moment as the Your booking is confirmed e-mail. |
the name, the booking number, the departure and its dates, every live traveller |
| Payment received | a payment on a booking becomes verified — finance verifies it, or an online (Razorpay) payment is recorded, which is verified when it is recorded. Not a share of a partner's on-account receipt allocated to the booking (amended 02/10/2026: an allocation over several bookings sent one notice per booking) | the name, the amount, the payment date, the booking number, the balance due; the receipt PDF attached (COMM-022) |
The values come from the same summary as the e-mails (COMM-001, COMM-002) and follow COMM-003: no passport, Aadhaar or PAN number, date of birth, phone number, e-mail address or link. Money is in rupees; a payment or booking in another currency gets no notice (the template says ₹).
Enforced by: triggers booking_whatsapp_confirmed (Booking, status into APPROVED from anything but APPROVED / PARTIALLY_CANCELLED) and payment_whatsapp_receipt (Payment, insert or status into verified, amount above zero, on a booking, and not an allocation — Payment."allocatedFromReceiptNo" is empty; allocate_on_account_receipt() sets it, migration 20261006130000) call booking_whatsapp_notice_queue(), which queues a CommunicationQueue row (whatsapp_booking_notice); the communications-dispatcher hands it to whatsapp-booking-notice. A trigger error never stops the approval or the verification. Tests: as above, and notifications_share_one_queue.sql (an allocation sends none).
COMM-021 · A notice is an approved template, never free text
Status: DECIDED 2026-09-30 (owner) · Owner: IT · Source: Meta WhatsApp Business Platform policy; AUD-024
A notice is a business-initiated message, so only a template Meta has approved is sent — whether or not the customer wrote in the last 24 hours. Each notice has two template names, both settings (COMM-025): one with a Document header (defaults booking_confirmed, payment_receipt) and a text-only one (defaults booking_confirmed_text, payment_receipt_text). A template is used only when it is synced from Meta (Admin → Integrations → WhatsApp → Sync from Meta, or the nightly sync), approved, and takes exactly five values ({{1}} … {{5}}). With neither template approved the notice is skipped and logged — Template not approved yet — and listed under Delivery problems. It is never sent as free text.
Enforced by: WhatsAppTemplate.usableForNotices (AUD-024, amended); whatsapp_notice_template() and the claim booking_whatsapp_notice_claim(); the edge function checks the variable count. Tests: whatsapp_booking_notices.sql, index.test.ts, whatsappBookingNotice.test.ts.
COMM-022 · The PDF goes privately
Status: DECIDED 2026-09-30 (owner) · Owner: IT · Source: the owner ("if possible should send PDF invoices or receipts"); ACC-073, ACC-074
The payment received notice carries the payment's receipt PDF (FIN-045) as the template's document header. The receipt is the one issue-document makes and keeps on the company Shared Drive (issued now if the payment has none). The sender reads its bytes from the Drive with the service account and uploads them to Meta's media store (POST /{phone-number-id}/media); the message names the media id and a file name such as Receipt-RCP-00012-BK-00123.pdf. No link to the file is made, and the file is never made public. If the PDF cannot be made or read, the text-only template goes instead; with no text-only template approved, the notice fails and is tried again.
There is no booking confirmation PDF and no invoice PDF (issue-document makes the receipt, the e-ticket sheet and the hotel voucher only). The booking confirmed notice therefore goes with its text-only template and attaches nothing; with only the document template approved it is skipped (No PDF to attach).
Enforced by: issue-document (service-role call, receipt only, returns the Drive file id and no link); whatsapp-booking-notice (uploadWhatsAppMedia, noticeComponents in _shared/whatsappNotice.ts). Tests: index.test.ts, whatsappBookingNotice.test.ts.
COMM-023 · Who receives a notice
Status: DECIDED 2026-09-30 (owner) · Owner: Management
The same people who receive the booking's e-mails (COMM-012): on a partner booking, the partner (the agency's phone); on a direct booking, the customer — for a payment, the payer when someone else pays and has a phone. The phone is normalised as everywhere else (+91… by default). A customer who opted out of WhatsApp — a STOP reply (AUD-022) or a recorded opt-out — gets nothing; a customer or partner with no mobile number gets nothing; a number blocked in the WhatsApp inbox gets nothing. Each is logged as skipped with its reason. Consent and the phone are checked when the notice is queued and again when it is sent.
Enforced by: booking_whatsapp_notice_queue() and booking_whatsapp_notice_claim() (customer_contact_allowed(…, 'whatsapp', 'transactional')); whatsapp-booking-notice (blocked contact). The receipt e-mail (COMM-038) picks its recipient by the same rule, through booking_notice_recipient(). Tests: whatsapp_booking_notices.sql, notifications_share_one_queue.sql.
COMM-024 · A notice goes once, and every notice is logged
Status: DECIDED 2026-09-30 (owner) · Owner: Engineering
A booking is confirmed on WhatsApp once, ever; a payment is acknowledged once. A second approval, a retry, a second dispatcher run or a double click sends nothing more. Every notice is logged — sent (with the template, the file name and the last two digits of the number), skipped (with the reason) or failed (with Meta's code and words, digits masked). A failed notice, and one skipped because no template is approved or no PDF can be attached, shows under Admin → Integrations → WhatsApp → Delivery problems for 30 days. When Meta or the Drive did not answer, the dispatcher tries again (at most three times); a refusal by Meta is final.
Enforced by: BookingWhatsAppNotice (unique booking, kind and payment; claimed queued → sending before the send, booking_whatsapp_notice_finish() records the result, a notice already sent is never handed out again); whatsapp_delivery_failures(). Tests: whatsapp_booking_notices.sql, index.test.ts.
COMM-025 · The notices are switched on by an administrator
Status: DECIDED 2026-09-30 (owner) · Owner: Admin
Both notices ship switched off. An administrator switches each on in Admin → Reminders → Automatic WhatsApp notices, after a confirmation, once the templates are approved in Meta. The same card names the four templates, shows what Meta holds for each (not synced, pending, approved, wrong header) and lists the last 50 notices with their result. Reading needs admin.integrations.view; changing needs admin.integrations.edit, and a paused login cannot change it. A template name must be one Meta accepts (lower-case letters, digits, underscores). Every change is audited (whatsapp_notice_settings_changed).
Enforced by: WhatsAppNoticeSetting (one row, no browser access), whatsapp_notice_settings(), whatsapp_notice_settings_save(). Tests: whatsapp_booking_notices.sql, WhatsAppNoticesCard.test.tsx.
Not built (WhatsApp): incoming orders or bookings over WhatsApp — the notices are outbound only, and the WhatsApp menu (COMM-032) takes an enquiry, never an order; a booking confirmation PDF and an invoice PDF (so nothing is attached to the booking confirmed notice); a notice for a booking created already approved by a traveller transfer; a notice for a receipt spread over several bookings — a receipt recorded over several bookings (no single booking), and a partner's on-account receipt allocated later, get none; only a payment recorded on one booking has one (until 02/10/2026 each allocation sent its own notice, one per booking — fixed, COMM-020); WhatsApp versions of the reminders (COMM-010), the refund and the visa e-mails; templates in another language (English only).
The WhatsApp menu
The owner, 1 Oct 2026: "I wanted actually workflow, where we send message to Alhuda Travels on whatsapp and it shows options from there to choose. Booking, groups available, status of current booking etc." The options chosen: Groups available, Booking enquiry, My booking status, Pay balance, Documents needed, Talk to the office — "also uploads passports and passport size pictures one by one to customers". Booking means an enquiry to sales: the standing rule that nothing is ordered or booked over WhatsApp holds.
Migration: 20261005160000_a_customer_uses_the_whatsapp_menu.sql. Edge functions: whatsapp-webhook (menu.ts, menuDeps.ts, media.ts), razorpay-order (a payment link). Tests: supabase/tests/a_customer_uses_the_whatsapp_menu.sql, supabase/functions/whatsapp-webhook/menu.test.ts, supabase/functions/whatsapp-webhook/media.test.ts, supabase/functions/razorpay-order/index.test.ts, src/components/admin/WhatsAppMenuCard.test.tsx, src/pages/pay/PayLink.test.tsx, src/components/documents/DocumentsSection.test.tsx. What each message says: WhatsApp menu. Switching it on: WhatsApp Cloud API.
COMM-030 · A customer who writes gets a menu
Status: DECIDED 2026-10-01 (owner) · Owner: Sales and Operations · Source: the owner, 1 Oct 2026 (above) When the menu is on, a customer who writes anything to the business number that is not an answer to a question the menu just asked — or writes hi, menu or 0 — gets a list: Groups available · Booking enquiry · My booking status · Pay balance · Documents needed · Talk to the office, and Sign up when the number has no login (TRV-017). Every reply is an answer to the customer's own message, inside WhatsApp's 24-hour window, so no template is needed. English only.
Where a number is in the conversation (the step, the answers so far) is kept per number in the database, readable by the webhook only; a step lapses after 30 minutes without a reply (a bank receipt is taken for 24 hours after Pay balance). A number gets at most 60 menu replies an hour.
The menu does not answer, and the message goes to the office as before: when it is off (the default); STOP / START (AUD-022); a sign-up keyword or the filled sign-up form (TRV-017 answers those whether the menu is on or off); a reaction; a number blocked in the inbox; a partner's number (COMM-031); a conversation handed to the office (COMM-036); a photo or file the menu did not ask for.
A message the menu answered raises no Work item and is not counted unread; the menu's replies are kept in the WhatsApp inbox, so staff see the whole conversation (WRK-002 is unchanged for everything else).
The menu is switched on by an administrator in Admin → Integrations → WhatsApp → WhatsApp menu, after a confirmation with a reason (admin.integrations.edit); the same card holds the office hours and the bank details.
Enforced by: whatsapp-webhook (menuDecide, runMenu), WhatsAppConversation (service role only), work_on_whatsapp_message(). Tests: menu.test.ts, a_customer_uses_the_whatsapp_menu.sql, WhatsAppMenuCard.test.tsx.
COMM-031 · The number is the identity
Status: DECIDED 2026-10-01 (owner) · Owner: Sales and Operations WhatsApp delivered the message from the number, so the number is the proof. My booking status, Pay balance and Documents needed show only live bookings where the number is — compared on its last ten digits (ACC-076) — the booking's customer's phone, its payer's phone, or a live traveller's own phone on that booking. Live: not a draft, cancelled, rejected or transferred-out booking, and not back from its trip more than 30 days ago. At most ten bookings.
A number on two or more customer records, or one that reaches the bookings of more than one customer, is shared and is shown nothing ("please talk to the office"). A business partner's number — the agency's own or one of its contacts' (PTR-085) — is kept out of the menu: it is pointed to the partner app or portal at most once a day, and its message goes to the office.
What is shown never includes a passport number, a date of birth, a phone number or an e-mail address (COMM-003).
Enforced by: whatsapp_menu_identity(), whatsapp_menu_booking_ids(), whatsapp_menu_bookings() (service role only). Tests: a_customer_uses_the_whatsapp_menu.sql, menu.test.ts.
COMM-032 · A booking enquiry is a lead, never a booking
Status: DECIDED 2026-10-01 (owner) · Owner: Sales Booking enquiry (or I'm interested on a departure) asks which departure (or Not sure → what kind of trip), how many adults, children and infants, the month, and the name when the number has no single customer record. The answers become a Lead with source WhatsApp, the number, the departure and the answers in its message — so it lands in the Work inbox's sales pool as a WhatsApp enquiry (WRK-002, 30 working minutes). The customer is told "Thank you — our team will call you on this number."
No customer record (LC-006), no booking, no seat held, no money. Three enquiries a day per number; the fourth is told to talk to the office.
Enforced by: whatsapp_menu_enquiry(). Tests: a_customer_uses_the_whatsapp_menu.sql, menu.test.ts.
COMM-033 · Booking status in plain words
Status: DECIDED 2026-10-01 (owner) · Owner: Operations
For each booking: the booking number, the departure and its dates (DD/MM/YYYY), the live travellers' names, the status in words (awaiting approval, being updated, on hold, confirmed, visa in progress, visa issued, tickets issued, travelling, completed), the total with GST, received and due in the booking's currency (₹ with Indian grouping) — the same figures as the booking e-mails (COMM-002) — and the next step. Several bookings: the customer picks one.
Enforced by: whatsapp_menu_bookings(); the words in menu.ts. Tests: menu.test.ts, a_customer_uses_the_whatsapp_menu.sql.
COMM-034 · Paying the balance from the chat
Status: DECIDED 2026-10-01 (owner) · Owner: Finance
Pay balance shows the total, received and due. For a rupee booking with something payable online, the customer gets a payment link: https://alhudatravels.in/pay/<token>. The token is random; only its hash is kept; it works once, for 30 minutes, for that booking and that amount (the agreed due — price + GST − receipts − cancellation credit, fin_booking_agreed_due() — whether or not finance has approved the booking voucher yet; FIN-033, owner 01/10/2026), for the customer or payer whose number asked; a newer link for the same booking supersedes it; five links an hour per number. Opening the page spends nothing; Continue to payment spends the link through razorpay-order, which re-checks the booking and the balance and makes the Razorpay order as the booking's customer — never staff (FIN-032). The payment is recorded only by razorpay-webhook from Razorpay's signed event, as for the app.
Without Razorpay keys the customer is told online payment is not available; a booking in another currency is told rupees only; less than ₹1 due is told to pay by bank transfer. A booking finance has not approved yet can be paid (owner, 01/10/2026: "allow online payment before approval, up to the booked amount"): the link is made for the agreed due, and the message first says "Our office is still confirming this booking. You can pay now — the amount is what you agreed to pay." Until 01/10/2026 such a booking was told online payment opens once it is confirmed (not_confirmed, retired by 20261006110000). My booking status on a booking still being checked says it can be paid now.
The same message gives the bank details from the setting (or "ask the office") and "After paying by bank transfer, send a photo of the receipt here." A receipt photo is kept on the Shared Drive and recorded as the payer's document Payment receipt, and a Work item tells the office. It marks nothing paid: finance verifies and records the payment as for any bank transfer.
Enforced by: whatsapp_pay_link_issue() / _use() / _release() / _order() (the amount from fin_booking_agreed_due(), 20261006110000), razorpay-order { payToken }, whatsapp_menu_document_add(). Tests: a_customer_uses_the_whatsapp_menu.sql, awaiting_approval_is_shown_and_payable.sql, razorpay-order/index.test.ts, PayLink.test.tsx, menu.test.ts.
COMM-035 · Documents one by one
Status: DECIDED 2026-10-01 (owner) · Owner: Operations and Visa Documents needed lists, per live traveller of a booking still to depart, what is missing, from the readiness the reminders use (COMM-010): the passport front page when the passport number or expiry is missing, the passport runs out within six months of return, or the visa has not started; the passport back page and a passport-size photo when the visa has not started. A part already sent and not rejected is not asked again.
Then it asks for them one by one — "Send the passport front page of
Staff see it in the customer's 360 → Files, marked From WhatsApp · for customers.edit); a rejected part is asked for again. Nothing about the traveller is changed by a file: staff type the passport details on the booking.
Enforced by: whatsapp_menu_missing_parts(), whatsapp_menu_document_target(), whatsapp_menu_document_add(), customer_document_review(), the CustomerDocument_review_guard trigger. Tests: a_customer_uses_the_whatsapp_menu.sql, media.test.ts, menu.test.ts, DocumentsSection.test.tsx.
COMM-036 · Talking to the office
Status: DECIDED 2026-10-01 (owner) · Owner: Sales
Talk to the office raises a WhatsApp Work item on the conversation (one open item per conversation, WRK-016) and marks it unread in the WhatsApp inbox, and tells the customer the office hours (a setting; default Mon–Sat 10:00–18:00 IST) and "A member of our team will reply here." From then on the menu is quiet: every message goes to the office, until the customer writes menu, or a member of staff has replied from the inbox and the conversation has then been quiet for 30 minutes.
Enforced by: whatsapp_menu_handoff(), whatsapp_menu_last_staff_reply(), menuDecide. Tests: menu.test.ts, a_customer_uses_the_whatsapp_menu.sql.
Not built (WhatsApp menu): any language but English; ordering or booking in the chat (an enquiry only — COMM-032); a partner's bookings (the partner app and portal do that); reading a passport from the photo (extract-passport is not run on WhatsApp files — it sends the image to an outside AI service and staff type the details anyway); the menu for staff numbers; documents other than the passport pages and the photo (a visa form, a ticket); a reminder pushed by the menu (it only answers).
COMM-040 · Company e-mail goes out through Google Workspace
Status: DECIDED 2026-10-02 · Owner: Management
The system's e-mail is sent through the company's Google Workspace first: as the mailbox admin@alhudatravels.in, From Alhuda Travels <noreply@alhudatravels.in> (a Send mail as alias), with replies to info@. Resend, then Mailjet, take over when Workspace is not set up or refuses a message. The admin test e-mail goes through the same chain and names the provider that sent it.
Enforced by: supabase/functions/_shared/gmail.ts and _shared/emailSend.ts, used by mailer and integration-test; test supabase/functions/mailer/gmail.test.ts. Runbook: Outgoing email.
Payment e-mails and the one queue
The owner, 02/10/2026: fold the leave e-mails into one general notification mechanism, with HR/leave as a category, so that no e-mail is ever sent twice — the database queues, the dispatcher sends, the mailer renders; no second mechanism. The finance staff guide review (Hamid, 02/10/2026) found that an online (Razorpay) payment got no receipt e-mail, that a rejected partner claim told nobody and vanished from the partner's Payments page, and that the receipt e-mail went to the booking's customer even on a partner booking.
Migration: 20261006130000_notifications_share_one_queue.sql. Tests: supabase/tests/notifications_share_one_queue.sql, src/services/bookingEmailSummary.test.ts, src/lib/api.finance.test.ts, src/lib/api.partnerPayments.test.ts, src/pages/partner/PartnerPayments.test.tsx, src/components/admin/NotificationCategoriesCard.test.tsx, apps/mobile/src/lib/partner.test.ts.
COMM-037 · A rejected payment claim tells the person who made it
Status: DECIDED 2026-10-02 (owner) · Owner: Finance · Source: the finance staff guide review (#10)
When finance rejects a payment that a partner or a customer reported themselves (I have paid — partner_submit_payment, customer_submit_payment), the person who made the claim is e-mailed Payment claim not accepted: the booking (or the group invoice), the amount, how it was paid, the reference, the day it was reported and the reason finance gave. A partner's claim goes to the agency's e-mail; a customer's to that customer (consent checked, AUD-022). Once per payment.
The reason is written for the claimant: it is the one staff reason that is sent outside the company, as an exception to REQ-002 — the reject dialog says so. A payment staff recorded and finance rejected is not a claim: nobody outside is told.
A rejected claim also stays on the partner's Payments page on the web and under Waiting → Not accepted in the partner app for 90 days, marked Not accepted, with the reason. It counts for nothing: it is not waiting and does not reduce what can still be claimed.
Enforced by: the trigger payment_email_notice → payment_claim_rejected_email_queue() (status from pending to rejected; the claimant is the Agent or Customer whose login created the payment); the mailer template payment_claim_rejected (service role only); src/lib/partnerPayments.ts (rejected), apps/mobile/src/lib/partner.ts (rejectedClaims). Tests: notifications_share_one_queue.sql, bookingEmailSummary.test.ts, api.partnerPayments.test.ts, PartnerPayments.test.tsx, partner.test.ts.
Not built: a customer's own screens do not list a rejected claim (the e-mail tells them); a WhatsApp for a rejected claim.
COMM-038 · The receipt e-mail is sent by the database, once, to the booking's e-mail recipient
Status: DECIDED 2026-10-02 (owner) · Owner: Finance · Source: the finance staff guide review (#8, #9, #10)
When a payment on a booking becomes verified — finance verifies it on the web or on the phone, or razorpay-webhook records an online payment, which is verified when it is recorded — the system e-mails a Payment received receipt (the booking block and the payment, COMM-001, COMM-002) once per payment. It goes to the people who receive the booking's e-mails (COMM-012): the partner on a partner booking; the customer on a direct booking — the payer when someone else pays and has an e-mail. A customer who opted out of e-mail gets none.
No receipt e-mail: for a share of a partner's on-account receipt allocated to a booking (no new money — the same as the WhatsApp notice, COMM-020); for a receipt recorded over several bookings (no single booking); for a refund (it has its own e-mail).
Until 02/10/2026 the browser sent this e-mail after a staff verify on the web only, to the booking's customer; an online payment and a verify on the phone sent none. The browser now sends no payment e-mail.
Enforced by: the trigger payment_email_notice (Payment, insert or status into verified, amount above zero, on a booking, allocatedFromReceiptNo empty) → payment_receipt_email_queue() → notify_enqueue('finance', 'payment_receipt:<payment id>', …); booking_notice_recipient(); the communications-dispatcher and the mailer template payment_receipt. Tests: notifications_share_one_queue.sql, api.finance.test.ts.
COMM-039 · Every automatic e-mail goes through one queue, by category, once
Status: DECIDED 2026-10-02 (owner) · Owner: Engineering and Admin · Source: the owner, 02/10/2026 (above)
Every automatic e-mail the database sends is a row in the one outbound queue (CommunicationQueue), sent by the communications-dispatcher through the mailer. Each row has a category — hr_leave (leave, LV-036 … LV-039), finance (receipts, claims not accepted, refunds), booking, visa, general — and an automatic e-mail has a key that names its event (payment_receipt:<payment>, leave:<application>:<step>:<recipient>). The same category and key are queued once: a second click, a re-run, a retry or a second door queues nothing more. A key cannot be set from the browser.
Each category the database queues has one switch. hr_leave is the Send leave e-mails switch HR has in Leave admin → Settings (LV-039) — not a second switch. finance is on by default and switched in Admin → Reminders → Automatic e-mails by category (admin.integrations.edit, a confirmation before switching off, audited notification_category_changed), where every category is listed. While a category is off nothing in it is queued; what was already queued still goes.
Enforced by: CommunicationQueue.category, "notifyKey", the unique index CommunicationQueue_notify_once (category, key) and the trigger CommunicationQueue_classify (fills the category; maps the leave key; drops a key set by a browser session); notify_enqueue(), notify_category_enabled(); NotificationCategory; notification_categories(), notification_category_save(). Tests: notifications_share_one_queue.sql, leave_emails.sql, NotificationCategoriesCard.test.tsx.
Note (2026-10-02, COMM-041): a category work — the e-mail copy of every work note (a job given to you, a job you gave finished, due soon, overdue; WRK-009) — is on by default and listed with finance.
Not built: switches for the booking and visa e-mails (the booking announcement LC-007, the visa milestone VISA-005 and the reminders COMM-014 keep their own rules and switches); a switch per e-mail inside a category; the WhatsApp notices in this queue's categories (they keep their own switches, COMM-025).
Notes the database keeps, rung on the phone
COMM-041 · A note the database keeps rings the phone
Status: DECIDED 2026-10-02 (owner) · Owner: Engineering · Source: the owner, 02/10/2026 — job notifications must be reminders in the web and phone app, not only e-mail (WRK-009)
Until now a note the database wrote into the notification inbox (AppNotification, TRV-010) rang no phone: only the browser or the app that made a change could ask push-send, so a reminder raised by a schedule could reach the inbox but never the phone.
Now a note the database writes for the phone is marked so ("pushWanted"). The communications-dispatcher, which already runs every five minutes, takes the marked notes written in the last day, stamps each one ("pushedAt") before it sends it — so two runs never ring one note twice — and asks push-send to deliver it to the person's devices without keeping a second row (store:false). A failure is written on the note ("pushError") and is not retried: one attempt per note. Because the stamp comes first, a run that stops between the stamp and the delivery loses that one ring; the note is still in the inbox. Pushes never delay e-mail: the dispatcher sends its e-mail queue first and rings the notes after, each push with an 8-second timeout and the run with a push budget (20 seconds; 10 for a kick) — notes it does not reach wait, unclaimed, for the next run. A scheduled run also puts back e-mail rows a stopped run left at processing for more than ten minutes (communication_queue_reap, "processingSince"), at most three tries in all. Right after a person's own act (a job handed over, finished), the web and the app ask the dispatcher to send now (its "kick"); a kick rings only the notes that person's act raised ("raisedBy"), never anybody else's.
Work notes (WRK-009, WRK-018) are the first to use it. The web and the app no longer call push-send for a work note, and push-send ignores any signed-in caller's push of kind work (any case, kept or not) — what an app build from before 08/10/2026 still sends after a hand-over — so the new holder's phone rings once. Every work note also has an e-mail copy in the category work ("Work: jobs given to you, finished, due soon and overdue"), on by default and switched in Admin → Reminders → Automatic e-mails by category (COMM-039).
Enforced by: AppNotification."pushWanted", "pushedAt", "pushError", "raisedBy" and the index AppNotification_push_pending_idx; work_tell() (service role and the database's triggers only; one note per person and key, unique index AppNotification_work_once); NotificationCategory row work, notify_category_of() (work_notice → work); supabase/functions/communications-dispatcher/keptPush.ts and index.ts; the push-send guard; the mailer template work_notice (service role only). All in 20261008150000_work_reminders.sql. Tests: supabase/tests/work_reminders.sql, src/services/keptPush.test.ts, src/services/workEmails.test.ts, src/lib/api.workNotes.test.ts, apps/mobile/src/lib/work.test.ts.
Note (2026-10-02): push-send no longer lets any signed-in login push a message to any login — see COMM-042.
Not built: the other notes the database writes (a group notice, a new booking's approvers, leave, role changes) still rely on the caller's push or ring nothing, as before — they are not marked "pushWanted". A note that could not be rung is not rung later. A person cannot choose which notes ring their phone.
COMM-042 · Only staff with a sending right write a push
Status: DECIDED 2026-10-02 · engineering, under ACC-001 · Owner: Engineering · Source: review of #552 (Hamid) — push-send accepted any signed-in login and sent its text to any login, customers and partners included
A push is written by the system or by staff who may notify people. The checks in the browser and the app are not a boundary (ACC-001); the push-send edge function is, and it decides:
- The service role (another edge function, such as the
communications-dispatcher, COMM-041) may send any push. - A staff login — active, not paused (ACC-070), not a portal login — that holds one of these rights may send a push with its own words:
communications.send(the staff push card on/communications),bookings.create,bookings.edit,bookings.cancel,bookings.cancel.approve,approvals.approve,finance.bookings.approve_finance,finance.bookings.reject_finance,partners.edit,partners.approve,groups.edit. These are the rights of the screens that ping people today: a booking sent for the sales check, a cancellation request and its decision, the sales and finance decisions, a partner's account approved or suspended, a group notice. - Anyone else signed in — a partner, a traveller, a tour leader without those rights — may only ring a notification the database already kept. The call must say
store:false, and each person it names must already have an inbox row (AppNotification, TRV-010) with the same title, message and kind (and the same record, when the call names one) written in the last 15 minutes. People without such a row are left out; with nobody left the call is refused (403). This is how a partner's new booking reaches the sales approvers' phones (booking_created_notifykeeps their rows, LC-007) and how a leader's group notice reaches the travellers' phones (trv_post_noticekeeps theirs). The database decided who is told and what it says; the caller only rings it. Each kept row rings once: the call stamps it ("pushedAt") before delivery, so the same notification cannot be rung again by anyone, and the link sent is the one the row holds, not the caller's. The rows are read and stamped 100 people at a time. - A signed-in caller's push of kind
workis still skipped (COMM-041).
Every caller: at most 500 people in one call; the title is cut to 200 characters and the message to 2,000 (the inbox's limits) — characters, so an emoji or a letter outside the basic range is never cut in half. A signed-in caller's link must be a page of the app (a path starting with /), and it cannot choose the icon. A deactivated login is refused (401/403).
Enforced by: supabase/functions/push-send/guard.ts (parsePushRequest, decidePushSend, keptRecipients, PUSH_SEND_PERMISSIONS) and index.ts (getAuthContext; the caller's rights read once with user_permission_names; the kept rows read and claimed with the service role, keptMatches, chunks, clipChars, holdsSendRight). The Communications push card's Send Notification button is behind communications.send (<PermissionGate>). Tests: src/services/pushSendGuard.test.ts.
Not built: a staff member who holds one of the rights may still word a push to anyone — the right is checked, not which people that screen would have told. The pushes the browser sends after a staff decision (cancellation, finance, partner status) are still sent from the browser, not by the database.