Amir TabatabaeiWork with me
← All work

Client engagement · Backend Engineer · Mar 2025 — Aug 2025

Sadaf

B2B Visa Capacity Platform

Delivered

FastAPIJinja2PostgreSQLJWTDocker

What mattered

Per-viewer agency visibility is derived from routing path position, not stored as a column.

  • The sending agency is derived from the viewer's index in the routing path. Never stored, so there is no filter to forget.
  • Two separate gates: the status decides which agency may act, the role decides what that user may do.
  • Entitlement is three stacked layers: plan tier, then feature rows carrying a min tier, then page permission.
  • Passwords use bcrypt_sha256, so a long input is not silently truncated at 72 bytes.

System map

ROTATEDHTTPSSESSIONGATESQLMRZAPPENDS3Panel (Jinja2)Service credsRole + plan tierCore APIPostgresPassport readerAudit logObject storage

Context

Travel agencies resell visa processing capacity to each other, four tiers deep; an application travels up that chain and is handed to a third party at the top. 40 agencies, ~100 agents, 500–600 applications a day.

Three of us worked on it over five months, with the first two spent planning. Hooman built CoreApp, where the business rules live. I built PanelApp, the agency-facing service and UI, against the contract we designed together.

Two rules shaped my side of the build: an agency can only see its immediate neighbours in the chain, and each submit action has to move money and status together.

Privacy as a query, not a column

Every agency in a chain can see the application. None may see past its immediate neighbours: a tier-3 agency must not learn what the top charges, and the top must not learn who originated it.

The obvious implementation — a sponsor_id column, plus filtering on read — is wrong twice over. It denormalises a fact that is different for every viewer, and it means one missed filter leaks pricing between competitors.

So it is never stored. Each application carries a routing path, and the answer is derived from where the viewer sits in it:

who sent it to me  = routing_path[N - 1]
who I sent it to   = routing_path[N + 1]      (N = viewer's index)

One row, and it renders a different, limited view to each agency that touches it. There is no denormalised field to fall out of sync, and no read path that can forget the filter, because there is nothing to filter.

Two separate gates on every action

Authority moves along the chain with the application, and that is separate from what a user's role permits. Both gates have to pass:

| Status | Which agency may act | |---|---| | TYPED | only the creator | | SUBMITTED_L2 / L1 / L0 | only the agency currently holding it | | READY_TO_SUBMIT | nobody — an automated process owns it |

Inside that, roles still apply: an AGENT can submit but cannot approve or revoke. Keeping the two checks separate meant neither could be quietly widened by a change to the other.

Entitlement and permissions, three layers deep

Not a role list — three stacked systems:

  1. Module — AgencySubscription gates which modules an agency can open at all, by plan tier (STARTER < STANDARD < PREMIUM < ENTERPRISE, each inheriting the one below).
  2. Feature — ModuleFeature rows carry a min_plan_tier, so individual features are gated by data rather than code, on a {module}.{section}.{action} convention: visa.application.create, accounting.analytics.anomaly. Selling a new feature tier is a row, not a deploy.
  3. Page — User → Role → Page → PagePermission (create/read/update/delete/approve/export), scoped by department.

Money and status move together or not at all

When an agency submits on behalf of one beneath it, the intermediary is both buying and selling. Two credit accounts are debited — the lower agency's with the middle one, the middle one's with the tier above — plus two status transitions, all in one commit.

There are five distinct routing scenarios with different money paths. A top-tier agency applying for itself has no credit account with itself, so it posts a direct bank expense instead.

The seam between the two services

PanelApp holds no business logic of its own; it drives CoreApp over HTTP. That integration is a single client that owns retries, timeouts and the mapping of CoreApp's error codes into something the panel can render, rather than requests calls scattered through the views.

Authentication is two-layered: the panel proves it is the panel with rotated service credentials, then the user proves who they are.

I wrote a complete fake of CoreApp before CoreApp existed: same endpoints, same response shapes, realistic data, so the front end and client demos never blocked on the backend. Switching to the real service was a base-URL change. I deleted it when the real one landed, which is how I know the contract held.

Also mine on this service: the security layer (CSRF, rate limiting with automatic banning, session handling, audit logging), and a dropdown page that was issuing one request per option, which I cut to two requests total and from several seconds to under one.

The visa form

Fields, validation and family-group size limits vary by visa type and nationality, so the form is driven by that pair rather than by one static schema. Every name is captured in English and Arabic, with transliteration between them.

Result

The client ran it inside their real operation for a week. Feedback from that week led to substantial changes in accounting and the visa form. It reached a finished internal state, then paused with the client's business for non-technical reasons.

Screens

The agency dashboard: a module launcher where Visa, Accounting and identity management are available and other modules are greyed out, which is the subscription tier deciding what this agency can open
The new visa application form: every name field paired English and Arabic with a translate action, passport photo requirements, and a group membership field marking a traveller as the main person or a dependent
User management, with the roles, departments and audit log sections that sit behind it