Skip to content
Syed Abdullah Shah

Guide · Architecture

Building a bilingual, multi-company business system

Lessons from a desktop ERP built for a transport company in Dubai. The office works in English and Urdu, customers are invoiced in English and Arabic, more than one company issues invoices from the same desk, and the whole thing has to keep working on the day a hard drive dies.

By Syed Abdullah Shah · · Python 3.12, PySide6 (Qt), PostgreSQL, ReportLab

1. Screen language and document language are different things

The first instinct with a bilingual app is one language switch for everything. It is the wrong one. The office wanted its screens in English, Roman Urdu or Urdu. The invoices go to customers and to the UAE tax authority, and have to read exactly the same whatever language the office happens to be working in that day. So the app has two separate language systems, and they are not allowed to meet.

On screen, every label goes through one function. Anything not yet translated comes back in English, not as a missing-key marker, so a half-translated screen still reads correctly:

def tr(text: str) -> str:
    """The text in the current language, or unchanged if not translated."""
    if _current == DEFAULT_LANGUAGE:
        return text
    return TRANSLATIONS.get(text, {}).get(_current, text)
app/i18n/__init__.py

Urdu is written right to left, so switching to it flips the entire application, not just the text. In Qt that is one call on the application object:

app.setLayoutDirection(
    Qt.LayoutDirection.RightToLeft if is_rtl()
    else Qt.LayoutDirection.LeftToRight
)
app/i18n/__init__.py, set_language()

Widgets already on screen keep the words they were built with, so the settings page asks for a restart rather than half-repainting the window. That is honest and simple, which beats clever and flickering.

The rule that keeps translated words off tax documents is checked, not just hoped for. A small test, runnable on its own with no test framework, fails if anything under app/reports/, where the invoices, vouchers and statements live, imports the language module at all:

FORBIDDEN = re.compile(r"^\s*(from\s+app\.i18n\b|import\s+app\.i18n\b|from\s+app\s+import\s+.*\bi18n\b)",
                       re.MULTILINE)
tests/test_pdf_language.py

The lesson: decide which outputs are legal documents before you add a language switch, and make the boundary something a test can check.

2. Arabic on a PDF invoice

PDF libraries draw glyphs in the order they are given. Arabic needs two extra steps: letters must be joined into their connected forms (reshaping), and the run must be reordered for right-to-left display (the bidi algorithm). Two small libraries do exactly that:

def _ar(text):
    if not _ARABIC_READY:
        return ""
    try:
        import arabic_reshaper
        from bidi.algorithm import get_display
        return get_display(arabic_reshaper.reshape(text))
    except Exception:
        return ""
app/reports/invoice_pdf.py

The subtle part is the font. The invoice looks for an Arabic-capable font already on the machine (Arial, Tahoma, Traditional Arabic, Dubai, or Noto Naskh or Amiri on Linux). If none is found, or the shaping libraries are missing, the Arabic labels are left out and the invoice prints in English only. An invoice that always prints beats a perfect one that sometimes crashes. Nobody at a customer’s desk can fix a missing font at five o’clock on the last day of the month.

3. Several companies, one system

The business runs more than one company from the same office and staff. Each invoice must carry the right company’s letterhead, Arabic name, tax number and stamp, so a customer can never confuse the two businesses. Each company’s details live in one table in the code, and every invoice records which company issued it.

How it records that is a trade-off worth being open about. The issuing company is stored as a marker in the invoice’s notes field, not in a column of its own:

def company_code_from_notes(notes):
    """Read the company marker back out of an invoice's notes."""
    for word in (notes or "").split():
        if word.startswith(COMPANY_MARKER):
            code = word[len(COMPANY_MARKER):].strip().lower()
            if code in COMPANIES:
                return code
    return DEFAULT_COMPANY
app/reports/invoice_pdf.py

The reason was continuity. Every invoice made before the second company existed has no marker, so it falls back to the original company and prints exactly as it always did, with no database migration touching live financial records. The cost is that anything which needs to filter by company has to read the notes. A dedicated column, filled in by a one-off migration, is the cleaner long-term shape, and it is where this should go as per-company reporting grows.

One detail that matters in practice: a batch export of many invoices keeps each page on its own company’s letterhead, so a month’s billing for both companies can go out in a single run without anyone sorting it first.

4. Two desks, one set of books

The office needed two people working at once on the same records. There is no cloud and no sync. One PC holds the PostgreSQL database, and both PCs run the program against it over the office network. A trip entered on one desk appears on the other, because there is only ever one copy of the data.

The hard part is not code. It is three separate settings, each of which on its own silently blocks the second PC:

SettingWhereWhat goes wrong without it
listen_addresses = '*'postgresql.confPostgreSQL only answers the PC it is on
host … 192.168.0.0/16pg_hba.confIt refuses other PCs even when they can reach it
inbound TCP 5432Windows FirewallThe traffic never arrives at all

So the project ships a small checker that changes nothing and simply reports which of the three is still missing. On a customer’s machine, a tool that tells you what is wrong is worth more than a script that tries to fix everything and fails halfway.

5. Backups that survive the bad day

A backup on the same disk as the database is not a backup: a dead drive or ransomware takes both. The backup service is built around four decisions:

safety = create_backup(note="before_restore")
app/services/backup_service.py, restore_backup()

One lesson came the hard way. Backups used to live in a folder beside the program, and installing a new version replaces that folder, so a routine update destroyed every copy kept there. They now live outside the program folder, and the app moves any old ones across once, copying and checking each file before the original is removed. Nothing is ever deleted automatically: a backup that quietly expires after thirty days is useless on the day you need last year’s figures.

What carries over to other projects

The system itself is written up as a case study. If you need something like it, see ERP and CRM development.