Introduction

Polaris started as an answer to a plain problem. Pakistani shops needed business software priced for a shop that could still keep up with the way they trade: on credit, across branches, in Urdu as often as in English. It now runs the counter and the khata for Pakistani businesses every trading day, with AI features that understand both languages.

This article walks through the architecture behind it and the scaling problems that shaped it.

System overview

Polaris is a Vue front end on a Django back end, with PostgreSQL as the system of record:

  • Front end: Vue, talking to the back end over a JSON API
  • Back end: Django, with the Django ORM for every read and write
  • Database: PostgreSQL
  • AI layer: Google Gemini 2.0 Flash via Vertex AI
  • Hosting: Google Cloud (Cloud Run for the application, Cloud SQL for PostgreSQL), or the shop's own server

The three pillars

Pillar 1: the counter never waits

In a retail shop, a sale has to feel instant. A cashier cannot watch a spinner while a queue forms at the counter.

Architecture decision: optimistic updates with reconciliation

The Vue client updates the screen first and confirms with the server second:

// Vue client: update the screen, then confirm with Django
async function recordSale(sale: SaleDraft) {
  // 1. Optimistically update local state
  applyToLocalStock(sale);
  applyToLocalKhata(sale);

  // 2. Send it to the Django API
  const result = await api.post("/api/sales/", sale);

  // 3. Reconcile if the server saw something the client did not
  if (result.data.conflicts?.length) {
    await reconcile(result.data.conflicts);
  }
}

The server stays the authority. If stock ran out on another till in the meantime, the reconciliation step corrects the screen and tells the cashier.

Pillar 2: every query belongs to one business

Each business's data has to stay isolated while all of them share the same infrastructure.

Architecture decision: tenant scoping in the ORM

Every tenant-owned model carries an organization foreign key, and every query goes through a queryset that scopes it:

from django.db import models


class TenantAwareQuerySet(models.QuerySet):
    def for_organization(self, organization):
        return self.filter(organization=organization)


class Sale(models.Model):
    organization = models.ForeignKey("accounts.Organization", on_delete=models.PROTECT)
    customer = models.ForeignKey("khata.Customer", null=True, on_delete=models.PROTECT)
    branch = models.ForeignKey("stores.Branch", on_delete=models.PROTECT)
    total = models.DecimalField(max_digits=15, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    objects = TenantAwareQuerySet.as_manager()

Views never build a query from a bare Sale.objects; they start from Sale.objects.for_organization(request.organization). The scoping lives in one place, so a new endpoint inherits it.

Pillar 3: AI that does real work

Two AI features earn their place in daily use:

  1. Questions in plain language: ask "Which product sold most in Lahore?" in Urdu or English
  2. Payment matching: suggest which invoices a payment settles

Architecture decision: Gemini with function calling, read-only tools

The model never sees the database. It picks a tool, and the tool runs a scoped, read-only ORM query:

from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Part, Tool

query_sales = FunctionDeclaration(
    name="query_sales",
    description="Sales totals by product, date range and branch",
    parameters={
        "type": "object",
        "properties": {
            "product_id": {"type": "string"},
            "date_from": {"type": "string"},
            "date_to": {"type": "string"},
            "branch": {"type": "string"},
        },
    },
)

query_stock = FunctionDeclaration(
    name="query_stock",
    description="Current stock levels by product and branch",
    parameters={
        "type": "object",
        "properties": {
            "product_id": {"type": "string"},
            "branch": {"type": "string"},
        },
    },
)

model = GenerativeModel(
    "gemini-2.0-flash",
    tools=[Tool(function_declarations=[query_sales, query_stock])],
)


def answer_question(question: str, organization) -> str:
    chat = model.start_chat()
    response = chat.send_message(question)
    call = response.candidates[0].function_calls[0]

    # Every tool reads through Sale.objects.for_organization(organization)
    rows = run_read_only_tool(call.name, dict(call.args), organization)

    response = chat.send_message(
        Part.from_function_response(name=call.name, response={"rows": rows})
    )
    return response.text

A question can only read. Anything that writes goes through the same permission checks as a person clicking a button.

Scaling challenges and solutions

Challenge 1: peak load during trading hours

Traffic follows shop hours: a late-morning peak, a bigger evening one, and Eid far above both.

Solution: autoscaling on Cloud Run

# Cloud Run service configuration
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "1"
        autoscaling.knative.dev/maxScale: "100"
    spec:
      containerConcurrency: 80
      timeoutSeconds: 300

Result: The Django service scales out for Eid and back down overnight, so the busy hours are the only ones we pay peak rates for.

Challenge 2: database connection limits

With autoscaling containers, database connections became the bottleneck. PostgreSQL has a hard connection limit, and every new Django instance wanted its own connections.

Solution: PgBouncer connection pooling

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5

Transaction pooling changes two Django settings. Persistent connections are pointless when PgBouncer owns the pool, and server-side cursors do not survive a connection that changes between transactions:

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": env("PGBOUNCER_HOST"),
        "CONN_MAX_AGE": 0,
        "DISABLE_SERVER_SIDE_CURSORS": True,
        # ...
    },
}

Result: The connection count stays flat as users grow. The pool size sets the load on PostgreSQL.

Challenge 3: reports during peak hours

Reports run heavy queries, and at month end they were slowing the till.

Solution: a read replica for reports, routed by Django

Django's DATABASE_ROUTERS decides which database each query uses. Writes always go to the primary; report code opts in to the replica:

# settings.py
DATABASES = {
    "default": {...},  # the primary, behind PgBouncer
    "replica": {...},  # a Cloud SQL read replica
}
DATABASE_ROUTERS = ["polaris.routers.ReportRouter"]
# polaris/routers.py
from contextlib import contextmanager
from contextvars import ContextVar

_use_replica: ContextVar[bool] = ContextVar("use_replica", default=False)


@contextmanager
def read_replica():
    token = _use_replica.set(True)
    try:
        yield
    finally:
        _use_replica.reset(token)


class ReportRouter:
    def db_for_read(self, model, **hints):
        return "replica" if _use_replica.get() else "default"

    def db_for_write(self, model, **hints):
        return "default"

    def allow_relation(self, obj1, obj2, **hints):
        return True

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        return db == "default"

A report then reads like any other ORM code, with the joins it needs fetched up front:

def sales_report(organization, date_from, date_to):
    with read_replica():
        return list(
            Sale.objects.for_organization(organization)
            .filter(created_at__date__range=(date_from, date_to))
            .select_related("customer", "branch")
            .prefetch_related("items__product")
        )

Result: Heavy reports run against the replica, so a month-end report never slows the till.

The replica moves the load somewhere else. Slow queries still have to be fixed one at a time: one reporting path went from 750ms to 230ms when its query was reshaped, on the same hardware.

Payment matching

One of the most used features in Polaris is payment matching against the khata.

Problem

Payments rarely match invoices exactly. A customer might pay 10,000 PKR against three invoices totalling 10,250 PKR. Matching that by hand takes hours across a ledger.

Solution: the model suggests, a person confirms

import json

from django.db import transaction


def suggest_allocation(payment):
    open_invoices = (
        Invoice.objects.for_organization(payment.organization)
        .filter(customer=payment.customer, status__in=["pending", "partial"])
        .order_by("due_date")
    )

    lines = "\n".join(
        f"- Invoice #{inv.id}: {inv.balance_due} PKR due {inv.due_date}" for inv in open_invoices
    )
    prompt = f"""
A customer paid {payment.amount} PKR.
Their open invoices:
{lines}

Reference note: {payment.reference or "None"}

Decide which invoices this payment settles. Allow partial payments and a 2% tolerance.
Return JSON: {{"matches": [{{"invoice_id": ..., "amount": ...}}], "confidence": 0.0 to 1.0}}
"""
    suggestion = json.loads(model.generate_content(prompt).text)

    # Low-confidence matches go to a person with the suggestion attached
    if suggestion["confidence"] < 0.9:
        return ReviewItem.objects.create(payment=payment, suggestion=suggestion)

    with transaction.atomic():
        return apply_allocation(payment, suggestion["matches"])

Result: Most payments now match without a person. The ones that do not arrive in the review queue with the model's reasoning attached.

Security architecture

Authorization: a permission on every action

Every action in Polaris sits behind a named permission, and roles are sets of permissions. Django's permission system carries it:

from django.contrib.auth.decorators import permission_required


@permission_required("sales.add_refund", raise_exception=True)
def issue_refund(request, sale_id):
    sale = Sale.objects.for_organization(request.organization).get(id=sale_id)
    # ...

A cashier's role can create a sale and not a refund; a manager's role adds refunds and reports. The check runs on the server for every request, whatever the screen shows.

Audit trail: append-only activity logs

Every sensitive operation is written to an append-only table:

CREATE TABLE audit_logs (
  id BIGSERIAL PRIMARY KEY,
  organization_id BIGINT NOT NULL,
  user_id BIGINT NOT NULL,
  action VARCHAR(100) NOT NULL,
  entity_type VARCHAR(50) NOT NULL,
  entity_id BIGINT,
  old_values JSONB,
  new_values JSONB,
  ip_address INET,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- The application role can insert and read, and nothing else
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;

Deployment and operations

Staged deploys

Every merge to main runs the tests, builds a container and deploys it with no traffic. A small share of traffic moves first, a health check runs, and only then does the rest follow:

# GitHub Actions workflow
name: Deploy production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: python manage.py test

      - name: Build container
        run: docker build -t gcr.io/polaris-prod/app:${{ github.sha }} .

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy polaris-api \
            --image gcr.io/polaris-prod/app:${{ github.sha }} \
            --region asia-south1 \
            --no-traffic

          # Gradual rollout
          gcloud run services update-traffic polaris-api \
            --to-revisions ${{ github.sha }}=10

          # Wait and verify
          sleep 60
          ./scripts/health-check.sh

          # Full rollout
          gcloud run services update-traffic polaris-api \
            --to-latest

Monitoring

  • Metrics: Google Cloud Monitoring with custom dashboards
  • Logs: Cloud Logging with structured JSON logs
  • Alerts: alerting policies on error rate and latency

Lessons learned

  1. Scope every query from day one. Putting tenant scoping in one queryset early saved us from a whole class of bugs.
  1. AI suggests; people confirm. The best features pair a model's suggestion with a person's confirmation.
  1. Optimize for the common case. Most transactions are simple. Complex edge cases can take slower paths.
  1. Urdu support matters. For Pakistani businesses, asking in your own language is what makes the AI usable at all.
  1. Cost decides adoption. Pricing from PKR 15,000 to 120,000 a month opened markets that SAP never could.

What's next

We're currently working on:

  • Voice interface: hands-free operation at the counter
  • Customer khata app: customers already check their balances from their phones; a dedicated app is in testing
  • Multi-store analytics: branch against branch comparisons on one screen

Interested in the technology behind Polaris? Visit polariserp.app or contact us to learn more.