Visa
One visa case per travelling passenger, a status flow the database enforces, and a public intake form that strangers can use safely.
Routes: /visa, /visa/:id (gated on visa.view); /visa/apply is public.
Rules: 06 · Visa (VISA),
CXL-050.
1. The status flow
with REJECTED reachable from APPLIED, SENT_TO_EMBASSY or UNDER_PROCESS. Those are
the only eight transitions (VISA-003). Anything else is refused.
change_visa_status(caseId, toStatus, reason) needs visa.edit and a reason of at least
three characters. It records who changed it and when. It is a no-op if the case is already
there, and it refuses a case whose passenger has been cancelled.
Telling the customer
ISSUED and REJECTED e-mail the customer, and the booking's partner when there is one
(VISA-005). The database does it inside
change_visa_status, so the desktop, the phone and any later caller behave the same:
visa_status_notifyqueues avisa_status_changerow inCommunicationQueuefor the customer (the booking's customer, else the case's own) and one for the partner. The mailer's template says the visa was issued or refused, with the traveller, the application number and the booking number.- The reason staff typed is never in it, nor in the queue row (REQ-002).
- A customer who opted out of e-mail, or has no e-mail on file, gets nothing. The answer says
which, in
notification.customerNotQueuedBecause(opted_out,no_email,no_customer). - Once per case, status and recipient — a unique index on the queue. A repeated status is a no-op anyway.
- The answer carries
customerNotified: trueonly when the customer's e-mail was queued by this call. The desktop toast then reads "customer e-mail queued"; the phone's sheet says "Customer notified by e-mail". Otherwise they say nothing was sent, and why. - The screen kicks the
communications-dispatcherstraight after, so the e-mail leaves at once; the five-minute run sends it anyway (Communications). - A failure to queue never undoes the status change; it is reported in
notification.error.
The browser no longer calls the mailer for a visa status change. "Documents needed" is still
e-mailed by the desktop when operations approval opens the cases.
Not built: a WhatsApp message for the milestone. It needs a template approved by Meta.
Recording an end state that happened elsewhere
Some visas were never worked here: a departure entered after it flew, or a case handled
entirely by a sub-agent or an embassy portal. The operations workbook records those as
ISSUED and nothing else. Record end state on the case detail writes that down in one
step (VISA-004):
POST /visa/:id/record-outcome→record_visa_outcome(caseId, toStatus, asAt, source), gated onvisa.editand confirmed before it is written.- Only
ISSUED,COLLECTEDorREJECTED, only on a case still atNOT_STARTED, only with a date it was true as at (never in the future) and a source — where the fact came from. - It writes one history row, marked
isRecordedFactwitheffectiveAtandrecordedSource, and setsVisaCase.statusSource = 'recorded'. The status history shows it as a recorded fact, dated as at, not as four workflow steps that never happened. - Recording the same end state twice does nothing. It never emails the customer.
It does not weaken the forward workflow: the eight transitions above are unchanged and a case operations has already started cannot be rewritten as history. Nothing else generates a message.
CANCELLED is not a status you can set
A ninth value exists, but change_visa_status refuses it: "Visa cases are cancelled only
by cancelling the passenger (CXL-050)." It is set by release_passenger_services when a
passenger's cancellation is approved. The case is kept with its documents and history; a
cancelled passenger never gets a new case (VISA-020).
What a browser session cannot do
VisaCase_guard allows a client to insert only a NOT_STARTED case, refuses any status
change by editing the row, and refuses deleting a case that has progressed or has documents.
VisaStatusHistory is append-only for client sessions, and the changedBy on every history
row is forced to the signed-in user.
2. Readiness
A passenger whose visa is not ISSUED or COLLECTED is shown as not ready to travel,
and a REJECTED visa never counts as covered in a group's readiness
(VISA-010). This is one of the twelve readiness items on
Customer 360.
A refused visa is not applied for again: the way forward is to cancel the traveller under the normal policy (VISA-011).
3. Documents
delete_visa_document needs visa.edit, a reason, and a document that belongs to the case.
It is a soft delete — the row is marked, never removed — and there is no delete policy on
the table at all, so a client session cannot hard-delete a document by any route. Merging or
cleaning up duplicate cases never destroys uploads or history
(VISA-030).
Adding a document
On the desktop, Upload on a case sends the file to drive-upload (kind visa_doc,
visa.edit) and records it through POST /visa/:id/upload. On the phone the same file is
recorded with add_visa_document(caseId, driveFileId, fileType), which accepts only a file the
server stored for that case as a visa document, and records it once however often it is
called (VISA-031). Either way the file sits on the Shared Drive under
Visa / the case and opens through drive-file (kind visa_file, visa.view).
4. On the phone
Home / More → Visa cases in the staff app (visa.view):
- The list is one call,
visa_phone_cases(q, filter, groupId, limit)(VISA-032). In progress / Issued / Rejected / All; a row of the departures with visas still open (soonest first, with how many are open); a search over the traveller, passport number (spaces and dashes ignored), application number, booking number, group code and customer code. Each card shows the next step and how many documents are on file. - The next step is one tap and a reason (
visa.edit), throughchange_visa_status. On Issued or Rejected the sheet says the customer is e-mailed and that the reason stays in the office; after it, "Customer notified by e-mail" only when it was queued, or why not. - A case (
visa_case_screen) shows where it stands, the next step, the documents — each opens in the app — and the history with each reason. - Add visa copy or a paper (
visa.edit): the visa copy, the passport or another paper, from the camera, the gallery or a PDF from the phone's files, under 10 MB.
Still on the desktop: opening a case, visa groups, billing the agent, recording an end state (VISA-004), deleting a document and the public intake queue.
Dates and order in the queue
The visa queue narrows to when a case was Added (Indian days, both ends included) and sorts by added, last updated or passenger name. The total and the stage tiles count the same date range as the list. Search, stage, dates and order stay in the page address (UX-030). The paging bar under the queue shows rows per page (25, 50 or 100), which cases are on screen out of how many ("1–25 of 312") and first, previous, next and last page — even when they all fit on one page (UX-030). The select-all box ticks the cases on the page being shown.
5. Public intake
/visa/apply is open to anyone, so it is treated as hostile input.
- The captcha fails closed. Without the Turnstile secret the form is refused, not skipped. A local-development override exists and is off by default.
- Rate limited per client IP and per phone number.
- Files travel with the application and are stored only after the captcha and the rate
limits have passed — at most five files, 10 MB each and 20 MB in all, a PDF or a photo —
on the company Shared Drive under Visa / Intake / the applicant (ACC-074). The intake
queue lists them with Open; a converted case carries them as visa documents. The old
upload-urlstep (a signed upload into a bucket) answers 410. - The staff notification email masks the passport number (AUD-020).
- The queue (
/sales/visa) reads one page at a time from the server with the search (name, phone, email, passport, city), status, a Submitted date range and a sort (submitted, last updated, name, status). It used to load every application and filter them in the browser (PLT-050, UX-030). The paging bar under it gives rows per page (25, 50 or 100), "1–25 of N applications" and first, previous, next and last page.
Converting an intake (convert_visa_intake, visa.intake.convert) carries across the
passport number and expiry, date of birth, nationality, visa type, travel dates and
attachments, and matches an existing customer by normalised passport number before
creating a new one. An open case for the same passport and customer is reused rather than
duplicated. Without a passport number no customer is created
(VISA-040).
Cases that arrive without a group land in a general visa group for the current month.
6. What the agent charged
Visas go to an agent — Rawasd Holidays and the like — who charges for the application. On Visa → Pipeline, tick the finished cases and press Generate invoice: name the agent, the charge per visa, their invoice number and its date, and one bill is raised against that agent's account (FIN-043).
- Only a finished visa is billed — issued, collected or refused. A refusal costs the same as an approval: the agent charged for the application either way. Cases still with the embassy are left out of the bill, and the dialog says how many.
- A case already on a bill is left out too. Billing it again is refused.
- The bill is held in the agent's own currency and converted for the books at the rate for the day they invoiced. With no rate for that day the bill is refused rather than posted at a guess — add the rate in Finance → Settings → Exchange Rate Management first.
- Each case then carries what it cost (
VisaCase."supplierCost"), so a departure's visa cost is the sum of its cases. - The button needs
finance.create. Moving a visa along does not let someone put a debt on the books.
Posting: Dr 5500 Visa & Documentation Expenses / Cr the agent's creditor account, pending a second person's approval like every other operational voucher.
Not done here: the bill is not recorded as a departure's cost row (FIN-041) — one bill can span several departures and a cost row holds one.
7. Permissions
| Action | Permission |
|---|---|
| See cases | visa.view |
| Open a case | visa.create |
| Change status, add or delete a document | visa.edit |
| Convert a public intake | visa.intake.convert |
| Bill a set of visas to an agent | finance.create |
Full list: PERMISSIONS.md §6.7.
8. Not built
- Cases opening automatically when operations confirms a passenger who needs a visa (VISA-002) — cases are still opened by hand.
- Visa fees as a policy. Whether a visa fee is a separate invoice line, whether it is refundable after submission, and whether a group invoice charges visa for a rejected or cancelled passenger are open (VISA-050, PRC-023). A visa fee already paid to the embassy is treated as non-refundable by default (CXL-050).
- A visa kanban and a passport custody register — Wave 4.
- Visa cut-off deadlines in the deadline radar (INT-120).
9. Where to look
| Concern | Path |
|---|---|
| Functions, guards, intake | supabase/migrations/20260918130000_tickets_visa_comms.sql |
| Public intake function | supabase/functions/visa-intake/ |
| Screens | src/pages/visa/, src/pages/VisaIntakeForm.tsx; phone: apps/mobile/src/app/visa.tsx, apps/mobile/src/app/visa-case/[id].tsx, apps/mobile/src/components/VisaParts.tsx, apps/mobile/src/lib/visa.ts |
| Telling the customer, phone documents and list | supabase/migrations/20261001140000_every_door_tells_the_visa_customer.sql |
| Readiness | src/lib/groupIntelligence.ts, src/components/journey/ReadinessChecklist.tsx |
| Billing an agent | supabase/migrations/20260926200000_a_visa_is_billed_by_its_supplier.sql, src/components/visa/VisaSupplierBillDialog.tsx |
| Tests | supabase/tests/tickets_visa_comms.sql, supabase/tests/visa_supplier_bill.sql, supabase/tests/every_door_tells_the_visa_customer.sql, apps/mobile/src/lib/visa.test.ts |
How the screens load
The visa queue, a visa case and a visa group each load with one request
(PRF-010): visa_list_screen, visa_case_screen and
visa_group_screen, read as you, with the permissions above. The queue's first
page brings the stage counts, the visa groups and the travel-group filter with
it (was 22 requests, 7 one after another); a case was 7 requests, a group 5.
Changing a filter keeps the current rows on screen until the new page arrives;
a case or group opened again shows at once and refreshes behind. The city list
behind the new-case form is read when the form opens, once per session. Opening a case's documents
still asks for them when the dialog opens. Routes: Operations screens API.