POS Research Hub
Architecture decisions backed by industry research. Every pattern evaluated, every tradeoff documented.
Rule Engine Architecture
How should our POS system handle business rules (price validation, discount limits, permissions) across modules?
// Industry Patterns Compared
We evaluated three industry patterns for implementing business rules in a multi-module TypeScript/Fastify system. Each was assessed for type safety, performance, testability, and cross-module coordination.
condition() + action()before('event')after('event')Condition/Action Rules
Rules defined in TypeScript with a condition function and an action function. Priority-based execution with short-circuit on failure. Used by the ts-rule-engine library pattern.
Before/After Hooks
Lifecycle events where "before" hooks can validate and cancel, "after" hooks handle side effects. Each module registers its own hooks. Inspired by ERPNext, Mongoose, and Fastify's own hook system.
JSON Rules Engine
Rules stored in database or JSON files. Declarative conditions with operators like greaterThan, equal. No recompilation needed to change rules. Used by json-rules-engine npm package.
// How Rules Are Registered
Side-by-side comparison of the ERPNext/Python pattern and our proposed TypeScript equivalent. The lifecycle hook model translates naturally to typed async functions.
# ERPNext: hooks in each module doc_events = { "Sales Invoice": { "before_submit": validate_credit_limit, "after_submit": update_stock_ledger, }, "Stock Entry": { "before_submit": check_warehouse_capacity, } } def validate_credit_limit(doc, method): if doc.outstanding > doc.credit_limit: raise ValidationError("Over limit")
// packages/rule-engine/src/index.ts export class RuleEngine { before(event: string, handler: HookFn) {} after(event: string, handler: HookFn) {} async emit(event: string, ctx: Context) {} } // apps/hq-server/rules/sales.ts engine.before('sale:complete', async (ctx) => { if (ctx.sale.total > ctx.store.creditLimit) throw new ValidationError('Over limit'); }); engine.after('sale:complete', async (ctx) => { await updateInventory(ctx.sale.items); });
// Decision Matrix
| Feature | Condition/Action | Before/After Hooks | JSON Engine |
|---|---|---|---|
| Type Safety | Full TypeScript | Full TypeScript | JSON only |
| DB Access | Via DI | Via DI | Custom facts provider |
| No Redeploy to Change Rules | Requires deploy | Requires deploy | Runtime changes |
| Performance | Fast (native code) | Fast (native code) | Slower (parsing) |
| Cross-Module Rules | Supported | Supported | Complex setup |
| Testing | Easy to unit test | Easy to unit test | Harder to isolate |
| Industry Adoption (Retail) | Moderate | High (ERPNext, Odoo) | Niche |
| Learning Curve | Low | Low | Medium |
Implemented: Before/After Hooks with Module-Level Registration
Closest to ERPNext/Odoo architecture, the most widely adopted pattern in retail ERP systems. Provides full TypeScript type safety, clean module separation, and aligns with Fastify's own lifecycle hook model we already use. Each module registers its own hooks — no centralized rule file. Testing is straightforward since each hook is a standalone async function.
// Implementation
The rule engine was built and integrated across both servers. Here is what was delivered:
packages/rule-engine/ — Generic before/after hook framework (26 tests)store-server/modules/sales/rules.ts — 7 sales rules (discount limits, price override, void validation)store-server/modules/inventory/rules.ts — Inventory adjustment rulesstore-server/modules/transfers/rules.ts — Transfer validation ruleshq-server/modules/sales/rules.ts — HQ-side sales rulesapp.ts// How to Add Rules
Adding a new rule follows a simple pattern — create a rule file in the module directory and register it at startup:
// 1. Define rules in modules/your-module/rules.ts import { RuleEngine } from '@pos/rule-engine'; export function registerMyRules(engine: RuleEngine) { engine.before('sale:complete', async (ctx) => { // Validation logic — throw to cancel if (ctx.sale.total < 0) throw new Error('Sale total cannot be negative'); }, { priority: 10, name: 'validate-sale-total' }); engine.after('sale:complete', async (ctx) => { // Side effects — logging, notifications await logSaleCompleted(ctx.sale); }); } // 2. Register in app.ts at startup import { registerMyRules } from './modules/your-module/rules'; registerMyRules(engine);
- ERPNext/Frappe Document Lifecycle Hooks — Python before/after submit pattern
- Odoo ORM Constraints & Computed Fields — Decorator-based validation
- Mongoose Middleware (Hooks) — Pre/post hooks for document lifecycle
- Fastify Hooks Documentation — onRequest, preHandler, preSerialization pattern
- json-rules-engine (npm) — Declarative JSON rule evaluation
- ts-rule-engine — TypeScript condition/action rule framework
Modular Sync Architecture
Can our sync system be modular like the rule engine? How do enterprise POS systems handle multi-store sync?
// Industry Findings
Enterprise POS systems universally use event-driven architectures with per-entity handlers — not monolithic sync services. Research across major vendors confirms this pattern.
- Microsoft Dynamics 365 — Row version tracking per entity type with global version number. Pull-based with incremental deltas. Sub-second change detection.
- Oracle Retail — Event-driven integration bus with per-entity handlers. Each retail domain (pricing, inventory, promotions) has its own sync module.
- SAP Retail — CIF (Customer Integration Framework) uses modular IDocs per entity type. Push-based notifications trigger targeted data downloads.
- Couchbase Sync Gateway — Global sequences per channel. Monotonic counters guarantee zero missed mutations. Used by many offline-first POS systems.
// Three Architecture Patterns
We evaluated three distinct approaches to sync architecture. The comparison below shows the tradeoffs.
syncVersion leads to missed updates. No hooks, no modularity, no extensibility.
sync/pull-service.ts → ALL entities
sync/push-service.ts → ALL entities
SyncHandler. The sync service is just an orchestrator. New entity = new handler file. Extensible and testable.
modules/products/sync.ts → product pull/push
modules/sales/sync.ts → sale push
modules/inventory/sync.ts → inventory push
events/product-changed.ts
events/sale-created.ts
projections/product-state.ts
Registry-Based Per-Entity Handlers (Pattern 2)
Matches our existing rule engine pattern (before/after hooks), is simple to implement, and follows what Microsoft Dynamics 365, Oracle Retail, and SAP all use internally. Each sync module lives next to the feature it syncs.
// Global Version Counter
The core fix: replace per-row syncVersion with a global monotonic sequence per entity type. This is the pattern used by SQL Server Change Tracking, Couchbase Sync Gateway, and Dynamics 365.
Each row increments its own version independently. Gaps are inevitable.
All changes get the next number from a single counter. No gaps possible.
-- Create a global sequence per entity type CREATE SEQUENCE sync_seq_products START WITH 1 INCREMENT BY 1; -- On any product change: stamp with global version CREATE OR REPLACE FUNCTION stamp_sync_version() RETURNS TRIGGER AS $$ BEGIN NEW.sync_version := nextval('sync_seq_products'); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Store pulls: "give me everything since my watermark" SELECT * FROM products WHERE sync_version > :lastSyncVersion ORDER BY sync_version ASC;
// Conflict Resolution Strategy
Per-entity ownership determines who wins in a conflict. This is not a generic CRDT — each entity has a clear source of truth.
| Entity | Source of Truth | Strategy | Direction |
|---|---|---|---|
| Products (catalog) | HQ | HQ Wins | HQ → Store |
| Prices / Tax Groups | HQ | HQ Wins | HQ → Store |
| Users / Roles | HQ | HQ Wins | HQ → Store |
| Sales | Store | Store Wins | Store → HQ |
| Inventory Count | Store | Store Reports | Store → HQ |
| Customers | Store creates, HQ merges | Last-Write-Wins | Bidirectional |
// Before/After Hooks for Sync
The rule engine integrates directly with the sync system. Each sync operation can trigger before/after hooks, enabling validation, enrichment, and side effects.
before('sync:pull:products', validateVersion) // Ensure version is valid before applying before('sync:push:sale', enrichSaleData) // Add store metadata before pushing
after('sync:pull:products', reportStockToHQ) // Push local stock state after pull after('sync:push:sale', updateSyncStatus) // Update UI sync indicator
// Transactional Outbox Pattern
Our current outbox implementation is correct — changes are written to sync_outbox in the same transaction as the data change. Recommended additions:
- idempotencyKey — UUID per outbox entry prevents duplicate processing on retry. HQ checks "have I processed this key?" before applying.
- syncVersion column on outbox — links outbox entry to the global version, enabling traceability and debugging of sync issues.
CREATE TABLE sync_outbox ( id SERIAL PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, payload JSONB NOT NULL, idempotency_key UUID DEFAULT gen_random_uuid(), -- NEW: duplicate prevention sync_version BIGINT, -- NEW: traceability created_at TIMESTAMPTZ DEFAULT now(), pushed_at TIMESTAMPTZ );
// 4 Known Bugs Identified
Per-row syncVersion misses updates
syncVersion is per-row, auto-incremented. If Product A has sv=2 but Product C already pushed sv=4, the store's watermark advances to 4. Product A (sv=2) will never be received by stores past sv=2.
store_product_dynamic never reflects reality
Created on first sync pull with onConflictDoNothing — never updated after. HQ calculates quantity by summing deltas from sale/adjustment pushes. If a push is lost, HQ quantity is permanently wrong.
Sync pull overwrites store quantity
Generic upsert did set: entity as any which overwrote ALL fields including quantity. Now: products entity excludes quantity from the update set.
Store never pushes product state
Store pushes events (sales, adjustments, transfers, customers) but never pushes “I have quantity=50, sellPrice=999”. HQ has no way to know the real state of the store’s inventory.
// Current (Broken) vs Correct Architecture
Product B modified → gets syncVersion = 3
Product C modified → gets syncVersion = 4
Store sets watermark = 4
syncVersion = syncVersion + 1 = 3Product B modified → global seq = 102, B.syncVersion = 102
// Implementation Roadmap
Five-phase rollout. Each phase is independently deployable and testable.
Global Version Counter
Create per-entity-type sequences (sync_seq_products, sync_seq_departments, etc.). Add trigger to stamp sync_version from global sequence on every INSERT/UPDATE. Re-stamp existing rows. Reset all store watermarks to 0 for full re-sync.
Per-Entity Sync Handlers
Create SyncHandler interface with pull(), push(), resolve() methods. Each module registers its handler: modules/products/sync.ts, modules/sales/sync.ts, etc. Sync service becomes a thin orchestrator that iterates registered handlers.
Before/After Hooks
Wire rule engine hooks into sync lifecycle: before('sync:pull:products'), after('sync:push:sale'). Enables validation, enrichment, and automatic side effects like reporting stock after product pull.
Product Stock Push
Store pushes full product state (product_stock entity) to HQ after every sale, adjustment, transfer, or PO receive. HQ never calculates store quantity — only records what the store reports. Fixes bugs 2 and 4.
Migration + Testing
Deploy updated pull handler. Force full re-sync on all stores. Monitor for missed updates. Verify store_product_dynamic matches actual store quantities. Add sync health dashboard.
- SQL Server Change Tracking — Global version counter pattern, CHANGE_TRACKING_CURRENT_VERSION()
- Couchbase Sync Gateway: Sequence Handling — Global sequence for sync, monotonic counters
- Dynamics 365 Row Version Change Tracking — Row version tracking per entity type
- Oracle Retail Integration Bus — Event-driven per-entity integration architecture
- SAP Retail CIF Documentation — Modular IDoc-based entity sync
- Transactional Outbox Pattern — Reliable at-least-once delivery with idempotency
- Event Sourcing vs Event-Driven — Comparison of event-sourced and event-driven architectures
- Dynamics 365 Commerce Architecture — Multi-store sync with push-based event notifications
POS Feature Roadmap
What features are we missing compared to industry standard POS systems, and how should we prioritize them?
// Current System Capabilities
Before identifying gaps, we cataloged what the system already has. The POS system covers most core retail operations:
- Multi-store management (HQ + stores) with sync
- Product catalog (SKU, barcode, departments, suppliers, specials)
- Tax system (rates, groups, compound taxes)
- Sales processing (cart, discounts, multi-payment, price validation)
- Inventory management (adjustments, cross-store lookup)
- Inter-store transfers with variance tracking
- Purchase orders (full lifecycle)
- User/role/permission system (65+ permissions)
- Worksheets (price/product/tax changes with approval flow)
- Customer management, Sales representatives
- Register sessions (open/close with X/Z/ZZ reports)
- 13 report types with Chart.js visualizations
- Dark mode, PWA, i18n (EN+ES), barcode scanner, receipt printing, offline-first
High Priority
7 featuresMedium Priority
9 featuresLow Priority
4 features// Missing Reports for Store Owners
Research identified key reports that store owners need but the system doesn't yet provide:
Gross Margin Report
Margin by product, department, and store. Answers: "Where am I making the most money?"
Shrinkage Report
Track inventory loss over time. Answers: "How much am I losing?"
Sell-Through Rate
Sales velocity vs stock received. Answers: "What sells fast vs what's stuck?"
Customer Retention
Repeat customer rate over time. Answers: "Are customers coming back?"
Store Comparison
Same metric side by side across stores. Answers: "Which store performs best?"
ABC Analysis
Classify products by revenue contribution. Answers: "Which 20% of products drive 80% of revenue?"
// Already Completed from This Research
Several items identified during research were implemented immediately:
- Best POS Features 2026 — Electronic Payments
- Essential POS Features — RetailCloud
- Multi-Store POS Guide 2026 — AppIntent
- Retail KPIs Guide 2026 — Improvado
- 25 Retail KPIs — NetSuite
- Retail Industry Metrics — Tableau
Architecture Decision Log
| Decision | Choice | Alternatives Considered | Status |
|---|---|---|---|
| Business Rules Engine | Before/After Hooks | Condition/Action, JSON Rules Engine | Done |
| Shared Code Strategy | pos-logic package | Copy-paste, git submodules | Done |
| Sync Version Tracking | Global version counter | Per-row version (current, broken) | Planned |
| Sync Architecture | Registry-based per-entity handlers | Monolithic sync, event sourcing | Planned |
| Conflict Resolution | Per-entity ownership (HQ/Store wins) | Generic CRDT, last-write-wins for all | Planned |
| Backend Abstraction | Command-based adapter | Direct fetch(), separate API clients | Done |
| Money Representation | Cents (integers) | Decimals, strings | Done |
| Tax Rates | Basis points (825 = 8.25%) | Decimals, percentages | Done |
| Store ↔ HQ Communication | WS notifications + HTTP data | Pure WS, polling, gRPC | Done |
| Outbox Pattern | Transactional outbox | Direct push, event sourcing | Done |