Skip to content

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

NOT_STARTED → APPLIED → SENT_TO_EMBASSY → UNDER_PROCESS → ISSUED → COLLECTED

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_notify queues a visa_status_change row in CommunicationQueue for 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: true only 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-dispatcher straight 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 on visa.edit and confirmed before it is written.
  • Only ISSUED, COLLECTED or REJECTED, only on a case still at NOT_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 isRecordedFact with effectiveAt and recordedSource, and sets VisaCase.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), through change_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-url step (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.