Small shops need three things from software: sell quickly, know what is in stock and trust the numbers. I built a self-hosted inventory, point-of-sale and purchasing system for them, with Spring Boot 4 (Java 25), Angular 21 and PostgreSQL, running in Docker. It works in English, Arabic (right-to-left) and French, and every shop is an isolated tenant.
This post is not a feature tour. It covers three design decisions I would make again, with the real code behind each.
1. Tenant isolation that nobody can forget to apply
In a multi-tenant system the worst bug is a missing WHERE tenant_id = ?. One forgotten filter in one repository method and a shop sees another shop's sales.
So I do not rely on developers remembering. A Hibernate filter is switched on by an aspect before every service method runs:
@Aspect
@Component
public class TenantAspect {
@PersistenceContext
private EntityManager entityManager;
@Before("execution(* com.enterprise.starter.kit.modules..service..*(..))")
public void enableTenantFilter() {
String tenantId = TenantContext.getTenantId();
if (tenantId == null) return;
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
}
}
The tenant id comes from the authenticated user's JWT and lives in a TenantContext. Entities that carry the filter definition are restricted automatically; global tables such as subscription plans are skipped.
What I like about this: the rule is enforced in one place, new modules inherit it for free, and a code reviewer only has to check that an entity declares the filter, not that every query remembers it.
The trade-off is that it is implicit. Anything that runs outside the service layer (scheduled jobs, raw SQL) does not pass through the aspect, so it needs its own care.
2. A stock ledger you never edit directly
The product table has a quantityOnHand column, and it is tempting to just update it. I did not. Every change goes through an append-only stock_movements table:
/**
* Audit ledger of every stock change. Positive quantity = stock in,
* negative quantity = stock out. Never written directly - only via
* the catalog adjust-stock, purchase-order receive, and sale-create flows,
* so it always stays consistent with CatalogItem#getQuantityOnHand().
*/
@Entity
@Table(name = "stock_movements")
public class StockMovement extends BaseEntity {
// catalogItem, movementType, quantity (+in / -out),
// referenceType + referenceId (which sale or purchase order caused it),
// optional variant id, note, occurredAt
}
There are only three ways stock moves: a manual adjustment, receiving a purchase order and creating a sale. Each writes a movement that points back to the thing that caused it.
That gives the shop owner a per-item history ("why is this at 3?") and gives me a debugging tool: when a number looks wrong, the ledger says which sale or purchase order did it.
3. A checkout that keeps working when the Wi-Fi does not
Shops lose connectivity. A cashier should not have to stop selling. The Angular POS queues cash sales locally and replays them when the browser reports it is online again:
const STORAGE_KEY = 'pos_offline_sale_queue';
const FAILED_STORAGE_KEY = 'pos_offline_sale_failed';
constructor() {
window.addEventListener('online', () => { this.isOnline.set(true); this.syncQueue(); });
window.addEventListener('offline', () => this.isOnline.set(false));
}
A few rules keep this honest:
- Only cash and "other" sales are queued. Card payments need a live round trip to create and confirm a payment intent, so they cannot be faked offline.
- The receipt is a preview. The server allocates the real invoice numbers when the sale syncs, so the offline receipt is built from a stored snapshot (totals, tax, line items), not from numbers I invented on the client.
- Failures are never swallowed. If the server rejects a queued sale, for example because stock ran out while offline, it moves to a "failed sales" list for the cashier to review.
Signals (isOnline, queue, syncing, failedSales) keep the UI simple: the template just reacts to connectivity, the queue length and the failed list.
A grounded assistant, without sending data anywhere
The same system has a chat assistant that answers questions such as "which items are low on stock?". It runs on a local model through Ollama, so shop data never goes to a third-party AI service.
Two choices make a small local model usable:
- Each question is sent with a fresh snapshot of that tenant's data, through the same tenant filter as every other screen.
- The snapshot starts with a summary and every product carries a precomputed
OKorLOWstatus, so the model never has to compare numbers itself. The prompt tells it to answer only from the snapshot and to say so when the answer is not there.
The assistant is read-only: it cannot sell, adjust stock or place orders.
What I would tell my past self
- Put the safety rule (tenant filter) where it cannot be skipped, not in a coding guideline.
- Treat stock as a ledger, not a number.
- Design the offline path around what cannot work offline, and be explicit about it.
- Small local models need pre-digested data, not raw tables.
Try it or talk to me
The system is part of a larger Spring Boot and Angular SaaS starter I maintain (the Arab Enterprise Kit). I also take freelance full-stack, backend and architecture projects: mahmoud-farouk-portfolio.vercel.app.
What would you do differently for the offline queue? I am curious how others handle replay conflicts.



Top comments (0)