In plain terms

SuperMart is the software a shop uses to sell things and the software the head office uses to run the shops: the till at the counter, the stock behind it, the staff who work there, and the reports the owner reads on Sunday night.

Its defining feature is unglamorous and worth real money: when the internet goes down, the till keeps working. The cashier carries on scanning and taking payment. When the connection comes back, every sale made during the outage is sent up and recorded exactly once - not zero times, and not twice.

Why it matters commercially: in a shop, an hour offline is an hour of refused customers. Retail internet in Bangladesh is not something a shop owner controls, and “sorry, our system is down” is a sentence that sends people to the shop next door.

The problem

A point of sale is the least forgiving screen in business software. Every other page in a business system can show a loading spinner and be forgiven. The till cannot: there is a customer holding cash, a queue behind them, and a shop whose revenue for the next ten minutes depends entirely on whether the software agrees to take the money.

SuperMart has to hold four promises at once, and they pull against each other:

  • Separate businesses must not see each other. Several retail companies share one platform.
  • Separate shops must not see each other. Inside one company, a cashier has no business reading another branch’s stock or takings.
  • Stock must not go negative. Two cashiers scanning the last unit is a normal Tuesday, not a rare edge case.
  • The till must keep selling offline. Connectivity is not a guarantee, and lost sales are the one failure the product exists to prevent.

The last promise is what makes this genuinely hard, because selling offline means acting without knowing the current truth. Everything below is the accounting of that tension.

Keeping businesses and shops apart

Between businesses: a separate database space each. There is a hard split between platform-level information - the subscription packages on offer, the list of customer companies - and each company’s own operational data. A request resolves which company it belongs to from the web address it arrived on, and sees only that company’s data for the rest of its life. Company identifiers are random rather than sequential, so a number in a web address reveals neither how many customers exist nor who came first.

The rule that keeps this honest: no direct links across the boundary. The moment one company’s records point at another’s, the separation is decorative rather than real. Anything genuinely shared lives in the platform space and is read, never joined across.

I wrote up the costs of this approach - more migrations to run, more connections to manage - in Schema-per-Tenant Django.

Inside a business: per-shop scoping. A retail company is a head office and several shops. The product catalogue is company-wide - a barcode means the same thing everywhere - but stock levels, sales and staff assignment belong to a specific shop. Every change that touches shop data passes an explicit permission check rather than a filter someone might forget:

def assert_user_branch_access(user, branch_id):
    scope = resolve_branch_scope(user)      # None == head office, unrestricted
    if scope is not None and branch_id not in scope:
        raise PermissionDenied("PERMISSION_DENIED")

The threat is not hypothetical. A cashier who edits a shop identifier in a web request, or a desktop till whose data has been tampered with, must be refused by the server - not merely hidden by the interface. What the user sees is a convenience; the server-side check is the actual control.

Permissions described once, enforced everywhere

The naive approach to permissions is a chain of “if this person is a manager…”. It works until the third customer asks for a role that does not exist yet - and by then the logic is scattered across two hundred screens.

SuperMart instead keeps a single register of every capability in the product (pos, inventory, reports, billing) with a level for each (view, edit, full). Roles are built by composing those grants. The backend enforces it:

class SaleViewSet(ModelViewSet):
    permission_classes = [HasFeaturePermission.require("pos", "edit")]

…and the interface hides or shows the matching page from the same register, so a page, its menu entry and its data access all come from one declaration.

Two business outcomes follow. Defining a new role for a customer takes minutes rather than a release cycle. And because anything not explicitly granted resolves to no access, forgetting to wire up a permission produces a locked page - a visible, reportable bug - rather than an open one nobody notices until it is a breach.

Billing across the boundary

Subscription billing is where multi-tenancy stops being an abstraction and starts being money. Platform-level records - packages, limits, whether a customer is on trial, active or overdue - live on the platform side. Each company’s own payment gateway credentials live on theirs, because those are their secrets.

Payments run through SSLCommerz, and payment notifications are treated as something that can and will arrive more than once. A duplicated notification settles the same invoice instead of creating a second one, which is the difference between correct books and a customer support conversation. Package limits are enforced in one place, so a limit applies whether the action came from the dashboard, the desktop till or the API.

Checkout: the moment everything else exists to protect

Taking payment is a single all-or-nothing operation, and the order of steps inside it is the whole design:

@transaction.atomic
def checkout(*, user, branch_id, lines, payments, idempotency_key):
    assert_user_branch_access(user, branch_id)

    existing = Sale.objects.filter(idempotency_key=idempotency_key).first()
    if existing:
        return existing                      # replay - no second decrement

    levels = (StockLevel.objects
              .select_for_update()
              .filter(branch_id=branch_id, product__in=[l.product for l in lines]))

    # ... verify quantities, else raise InsufficientStock

    sale = Sale.objects.create(..., idempotency_key=idempotency_key)
    record_payments(sale, payments)
    apply_stock_movements(sale, levels)
    return sale

Two different business problems, two different mechanisms - and confusing them is the classic mistake:

  • Locking solves two people at once. Two cashiers, one unit of stock. The second transaction waits for the first, then sees the true quantity and fails cleanly with a clear message. The cashier tells the customer straight away instead of the shop finding out at stock-take.
  • A unique request key solves retries. The same sale submitted twice - a timeout, a double-click, a resend after a crash - resolves to the same single sale. Nobody is charged twice; stock is never deducted twice.

Because stock is held per shop, one branch’s busy checkout never slows another’s. That is the payoff of the two-tier data model.

The offline till

The desktop till is a small, fast native application with its own local database. The choice was driven by what a shop actually runs: a modest computer that also has a browser and a spreadsheet open.

The design rule is one sentence: the till proposes, the server decides.

Online, the till is a thin window onto the platform. Offline, it serves what it already knows from its local copy and writes each completed sale - the full request, plus a unique key generated on the spot - into a local outbox marked as pending. Local stock figures shown to the cashier are a best guess for display; the server remains the authority.

On reconnect, the outbox is drained in order and each pending sale is sent unchanged, original key included. The server’s duplicate check turns every repeat into a no-op that returns the original sale. If an item genuinely sold out online while the till was offline, that row is marked failed with the server’s reason and shown to the cashier - never silently dropped, because a deleted sale is a sale nobody can audit.

Local data is tied to the specific business and shop and wiped when either changes, so one company’s pending sales can never be posted under another’s account.

The full design - key lifecycle, crash windows, conflict handling and what I would change - is written up in Selling With the Wi-Fi Down.

The trade-off I accepted, deliberately

Staying open beats being perfectly accurate. Offline stock numbers can be briefly wrong, and I chose that on purpose: the alternative was refusing sales whenever the till was uncertain, which is exactly the failure the product exists to prevent.

The mitigations are honesty rather than cleverness - a visible offline indicator, an explicit list of sales that failed to sync which the cashier must resolve, and a fast refresh the moment the connection returns. The shop is never misled about what the system knows.

The result

  • Sales continue through internet outages, so revenue depends on the shop being open rather than on the connection being up.
  • Every offline sale lands exactly once - no lost takings at close of day, no customer charged twice.
  • Overselling is prevented at the moment of sale, not discovered at stock-take.
  • A new retail customer joins the existing platform with isolated data and their own branding, no separate installation to maintain.
  • New staff roles take minutes to define, and a forgotten permission fails safely closed.

What it demonstrates

Four hard problems that all have to hold at the same time: customer separation enforced by the database rather than by discipline, permissions that compose instead of branching on job titles, correctness when two people act in the same second, and a reliable agreement between a desktop till, an unreliable network and a server - the kind of work that decides whether retail software is trusted with the money.