The problem
A point of sale is the least forgiving surface in business software. Every other screen in an ERP can show a 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.
Sortorium is a multi-tenant POS and ERP platform for retail businesses, and it has to hold four constraints at once that individually push in opposite directions:
- Tenants must not see each other. Separate businesses share one cluster.
- Branches must not see each other. Inside one business, a cashier at one shop has no business reading another shop’s stock or takings.
- Stock must not go negative. Two cashiers scanning the last unit is a normal Tuesday, not an edge case.
- The till must keep selling offline. Retail internet in Bangladesh is not a guarantee, and “sorry, the system is down” is not an acceptable output.
The last one is what makes this interesting, because offline-first is fundamentally in tension with the other three. Everything below is the accounting of that tension.
Isolation in two dimensions
Between tenants: schema-per-tenant PostgreSQL. Built on django-tenants, with a hard split between a public schema holding platform-level concerns - the product catalogue you can subscribe to, the tenant registry, subscription records - and a tenant schema per customer holding everything operational. The request resolves a tenant from the hostname or an explicit X-Tenant-Subdomain header and sets the search path for its lifetime. Tenants carry UUID identifiers rather than sequential integers, so a tenant ID in a URL leaks neither existence nor ordering.
The rule that keeps this honest: no cross-schema foreign keys. The moment a tenant table points at another tenant’s row, the isolation is decorative. Anything genuinely shared lives in public and is read, never joined across.
I wrote up the trade-offs of this approach - the migration multiplication, the connection-pool discipline, the catalog pressure - in Schema-per-Tenant Django.
Inside a tenant: branch scoping. A retail business is a head office and several shops. The product catalogue is tenant-wide - a SKU means the same thing everywhere - but StockLevel, sales, and staff assignment are per branch. Every write that touches branch data passes through an explicit access assertion rather than a filter someone might forget:
def assert_user_branch_access(user, branch_id):
scope = resolve_branch_scope(user) # None == tenant admin, unrestricted
if scope is not None and branch_id not in scope:
raise PermissionDenied("PERMISSION_DENIED")
The threat model here is not hypothetical. A branch-assigned cashier who edits ?branch= in a request, or a desktop client whose payload is tampered with, must be rejected by the server - not merely hidden by the UI. Frontend scoping is a convenience; the assertion is the control.
Permissions keyed on features, not roles
The naive version of authorisation is if user.role == "manager". It works until the third customer asks for a role that does not exist yet, and then it is scattered across two hundred views.
Sortorium instead has a feature registry: every capability in the product is a key (pos, inventory, reports, billing) with a level (view, edit, full). Roles are compositions of feature grants, seeded from the registry. The backend gates with a DRF permission class:
class SaleViewSet(ModelViewSet):
permission_classes = [HasFeaturePermission.require("pos", "edit")]
The frontend gates the same key through a PermissionGuard component and a ROUTE_PERMISSIONS map, so a route, its sidebar entry and its API all derive from one declaration. Adding a page means adding a registry entry, not remembering to add a check in three places.
The property that matters: default deny. A feature key with no grant resolves to no access, so forgetting to wire a permission produces a locked page - a visible bug - rather than an open one.
Billing across the schema boundary
Subscription billing is where multi-tenancy stops being an abstraction and starts being money. The split I settled on:
- Public schema: the platform product catalogue, packages, limits, and each tenant’s subscription state (
trial,active,past_due). - Tenant schema: the tenant’s own payment gateway credentials, because those are the tenant’s secrets and belong on their side of the wall.
Payments run through SSLCommerz. Callbacks and webhooks are treated as at-least-once and replayable, which means the transaction handler is keyed and idempotent - a duplicated callback settles the same invoice, it does not create a second one. Package limits are enforced at the service layer rather than the serializer, so a limit applies whether the write arrives from the dashboard, the desktop till or the API.
Checkout: the transaction everything else protects
Checkout is one atomic block, and the order of operations 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 problems, two different mechanisms, and conflating them is the classic mistake:
select_for_updatesolves concurrency. Two cashiers, one unit of stock. The second transaction waits for the first, then sees the true quantity and fails cleanly withINSUFFICIENT_STOCK. Pessimistic locking is right here because POS checkouts are short and contend on a handful of hot SKUs - a conflict means “the other cashier got it”, not “merge our edits”.- The idempotency key solves retries. Same request twice - a timeout, a double-click, an outbox re-drain after a crash - resolves to the same sale. The key is a database uniqueness constraint, not a
filter().first()on trust: the second concurrent insert loses onIntegrityErrorand is handled by fetching the winner.
Branch A’s lock does not block branch B, because StockLevel is per branch. That is the payoff of the two-tier data model.
The offline till
The desktop client is Tauri 2.0 with a React front end and a local SQLite database. Choosing Tauri over Electron was mostly about what a shop actually runs: a small binary, modest memory, and a Rust process that owns the local database and the credentials rather than shipping a browser to do it.
The design rule is one sentence: the client proposes, the server decides.
Online, the client is a thin shell over the API. Offline, a SyncEngine serves reads from the SQLite cache and writes the completed sale - the full POST /pos/checkout/ payload plus a client-generated UUID idempotency_key - into a checkout_outbox table with status pending. Local stock decrements are optimistic display only; the server remains the authority.
On reconnect, pushOutbox() drains the queue FIFO and posts each row’s payload unchanged, original key included. The server’s idempotency check turns every duplicate into a no-op that returns the original sale. If stock genuinely ran out online while the till was offline, the row is marked failed with the server’s reason and surfaced to the cashier - it is never silently dropped, because a deleted row is a sale nobody can audit.
Cache and outbox rows are keyed by tenant subdomain and branch, and wiped on tenant switch, so one tenant’s pending sales can never post under another tenant’s token.
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
Availability over local accuracy. Offline stock figures can be wrong, and I chose that deliberately: the alternative was blocking 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 banner, an explicit failed-sync queue the cashier must resolve, and a fast pull on reconnect.
What it demonstrates
Four hard problems that have to hold simultaneously: tenant isolation enforced by the database rather than by discipline, authorisation that composes instead of branching on role names, transactional correctness under real concurrency, and a distributed-systems contract - outbox plus idempotency - stretched across a desktop client, a flaky network and a Django service.