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
Sync Infrastructure Redesign
Current per-row syncVersion causes missed updates across stores. How do we fix the sync system to guarantee delivery of every change?
// Industry Research
- SQL Server Change Tracking uses a global version counter per table —
CHANGE_TRACKING_CURRENT_VERSION()returns the current global watermark. Every row change gets stamped with this monotonically increasing number. - Couchbase Sync Gateway uses global sequences — each mutation gets a sequence number from a single counter. Clients pull "since sequence X" and get all changes.
- Dynamics 365 uses row version tracking — a global version number per entity type, incremented on any row change. Clients request changes since a version and get a complete, ordered set.
All three systems agree: global monotonic version per entity type is the correct pattern. Per-row version is fundamentally broken for sync.
// The 4 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. Worksheets and edits silently fail to reach stores.
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. Worksheet Apply changes don't update it either.
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
// Migration Strategy
The migration must be done carefully — stores will need a full re-sync after the change.
Create/use sequence sync_seq_products. When any product is modified, increment the global sequence and stamp the row.
Departments, tax_rates, tax_groups, suppliers, tenders, users, roles, permissions — each entity type gets its own global sequence.
When store modifies a product locally, add to outbox: entity_type: 'product_stock' with full state.
Remove updateStoreDynamic calls from sync push handler. store_product_dynamic.quantity only comes from what the store reports.
Worksheet Apply bumps the GLOBAL syncVersion. Store receives via pull, updates local, pushes state back, HQ updates store_product_dynamic.
Sequential global versions for all existing rows. Force full re-sync on all stores (reset lastSyncVersion to 0).
Deploy updated pull handler, verify all stores complete full re-sync, monitor for missed updates.
// Scale Considerations
With 20+ stores and 10K+ products, the new push model generates manageable volume:
- Sale touches ~3 products → 3 outbox entries per sale
- 500 sales/day × 3 = 1,500 pushes/day per store
- 1 worksheet × 50 products = 50 pushes after pull
- 20 stores = ~31,000 product_stock pushes/day total
- Same volume as current sale pushes — no infrastructure change needed
- SQL Server Change Tracking — Global version counter pattern, CHANGE_TRACKING_CURRENT_VERSION()
- Couchbase Sync Gateway: Sequence Handling — Global sequence for sync
- Dynamics 365 Row Version Change Tracking — Row version tracking per entity
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 |
| 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 |