POS Research Hub

Architecture decisions backed by industry research. Every pattern evaluated, every tradeoff documented.

Last updated: March 16, 2026
4
Research Topics
17
Sources Cited
6
Patterns Evaluated
20
Missing Features ID'd

Rule Engine Architecture

The Question

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.

Pattern 1: Condition/Action Rules
Define rule with condition() + action()
Engine collects all rules, sorts by priority
Evaluates conditions sequentially
Executes matching actions
Short-circuit on first failure
Pattern 2: Before/After Hooks
Module registers before('event')
Before hooks run: can validate or cancel
Core action executes
After hooks run: side effects, logging
Module registers after('event')
Pattern 3: JSON Rules Engine
Rules defined in JSON (DB or file)
Engine parses conditions + operators
Evaluates against provided "facts"
Returns events for matching rules
Caller handles events
Pattern 1

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.

Used by: Custom enterprise systems, internal rule frameworks
TypeScript typed Requires redeployment Fast execution Rules scattered in code Easy to test No runtime changes
Pattern 2 — Recommended

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.

Used by: ERPNext, Odoo, Mongoose ODM, Fastify, WordPress
Clean separation More boilerplate Modular by design Requires redeployment Full type safety Hook ordering matters DI-friendly
Pattern 3

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.

Used by: Data-driven promotion systems, A/B testing platforms
No redeploy Slower execution Runtime changes Verbose JSON Non-dev editable Harder to type
Proposed Architecture
packages/rule-engine/
Generic framework: before() / after() / emit()
imported by
apps/store-server/rules/
POS rules, inventory rules, transfer rules
apps/hq-server/rules/
Product rules, worksheet rules, sync rules

// 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 (Python)
# 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")
Our TypeScript Equivalent
// 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
Our Decision

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 rules
store-server/modules/transfers/rules.ts — Transfer validation rules
hq-server/modules/sales/rules.ts — HQ-side sales rules
Global rules placeholder for both servers
Rules registered at server startup in app.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:

Adding a Rule (TypeScript)
// 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);
Sources

Modular Sync Architecture

The Question

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.

43.2%
Reduction in System Load
<500ms
Propagation Latency
6-12h
Industry Avg (Batch)
4
Enterprise Vendors Studied
  • 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.

Pattern 1: Monolithic Sync
Current — Broken
Single Service Handles All
One sync service handles every entity type. Per-row syncVersion leads to missed updates. No hooks, no modularity, no extensibility.
sync/pull-service.ts → ALL entities sync/push-service.ts → ALL entities
Pattern 2: Registry-Based Handlers
Recommended
Per-Entity Sync Modules
Each module registers its own 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
Pattern 3: Event-Sourced Sync
Overkill
Event Log + Replay
All changes stored as immutable events. Sync replays events to reconstruct state. Full audit trail but massive complexity. Overkill for our use case.
events/product-changed.ts events/sale-created.ts projections/product-state.ts
Decision

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.

Before: Per-Row Versions

Each row increments its own version independently. Gaps are inevitable.

1 2 ? 4 ? ? ? 8
Gaps cause missed updates. Store watermark jumps past unsynced rows.
After: Global Sequence

All changes get the next number from a single counter. No gaps possible.

1 2 3 4 5 6 7 8
Monotonic → nothing is ever missed. Pull "since N" always works.
SQL Server Change Tracking Pattern
-- 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 Hooks (Validation)
before('sync:pull:products', validateVersion)
// Ensure version is valid before applying

before('sync:push:sale', enrichSaleData)
// Add store metadata before pushing
After Hooks (Side Effects)
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.
Enhanced Outbox Schema
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

Bug 1 — Critical

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.

Bug 2 — Critical

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.

Bug 3 — Fixed

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.

Fixed 2026-03-15
Bug 4 — Critical

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

Current: Per-Row Version (Broken)
1
Product A modified → gets syncVersion = 2
Product B modified → gets syncVersion = 3
Product C modified → gets syncVersion = 4
2
Store pulls "since 0" → gets A(2), B(3), C(4)
Store sets watermark = 4
3
Product A modified again → syncVersion = syncVersion + 1 = 3
4
Store pulls "since 4" → WHERE syncVersion > 4
Product A (sv=3) is MISSED. Store never gets the update.
Correct: Global Version Counter
1
Product A modified → global seq = 101, A.syncVersion = 101
Product B modified → global seq = 102, B.syncVersion = 102
2
Store pulls "since 0" → gets all, sets watermark = 102
3
Product A modified again → global seq = 103, A.syncVersion = 103
4
Store pulls "since 102" → WHERE syncVersion > 102
Product A (sv=103) IS included. Nothing is ever missed.
Store Product State Push (New)
Store: Product modified
Sale, adjustment, transfer, PO receive
add to sync_outbox
Outbox: product_stock entity
{ productId, quantity, sellPrice, costPrice, taxGroupId }
push to HQ
HQ: UPDATE store_product_dynamic
SET quantity=X, sellPrice=Y, costPrice=Z
result
store_product_dynamic = TRUE state
HQ never calculates, only records what store reports

// Implementation Roadmap

Five-phase rollout. Each phase is independently deployable and testable.

Phase 1

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.

Phase 2

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.

Phase 3

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.

Phase 4

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.

Phase 5

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.

Sources

Tauri + Web Shared Code

The Question

How to share TypeScript logic between Tauri desktop apps and web browser apps in a monorepo?

// What We Found

  • pnpm workspaces + Turborepo validated by GitButler (50k+ stars), Clash Nyanpasu, and many Tauri community projects
  • Shared package pattern is the recommended approach from the Tauri community — extract all TypeScript logic into a shared package
  • Backend adapter pattern solves the "invoke duplication problem" — one API interface that maps to invoke() in Tauri or fetch() in web
  • Dependency injection via provide/inject for platform-agnostic stores — Vue composables don't know if they're in a Tauri or web context
Shared Code Architecture
packages/pos-logic/
Shared stores, composables, config, modules
imported by all apps
apps/web-client/
Vue PWA (browser)
apps/pos-terminal/
Tauri POS (desktop)
apps/store-manager/
Tauri Store Mgr
apps/hq-manager/
Tauri HQ Mgr
Backend Adapter Pattern
execute(command, args)
Unified API — packages/backend-adapter/
routes to
Web: fetch()
HTTP GET/POST/PUT/DELETE
Tauri: invoke()
IPC to Rust backend

// What We Implemented

12 shared Pinia stores in pos-logic
3 composables (barcode, theme, version)
Shared config + modules registry
All 4 apps import from @pos/pos-logic
3 Tauri binaries compile successfully
86 pos-logic unit tests passing
248 web-client tests passing
~90 backend adapter commands mapped
Dynamic import via Function() for Tauri
i18n shared (en + es) across all apps

// Key Insight: Backend Adapter Command Mapping

The web adapter automatically maps ~90 commands to HTTP endpoints. Commands follow a naming convention (get_products → GET /api/v1/products, create_sale → POST /api/v1/sales) with explicit overrides for non-standard routes. This means Pinia stores call execute('get_products') and it works identically in Tauri (via Rust IPC) and web (via HTTP).

Backend Adapter — Command Routing
// packages/backend-adapter/src/index.ts

// 1. Command → Endpoint
commandToEndpoint('get_products')     // → '/api/v1/products'
commandToEndpoint('create_sale')      // → '/api/v1/sales'
commandToEndpoint('get_sales_reps')   // → '/api/v1/sales-reps'

// 2. Command → Method (by prefix convention)
commandToMethod('get_*')    // → GET
commandToMethod('create_*') // → POST
commandToMethod('update_*') // → PUT
commandToMethod('delete_*') // → DELETE

// 3. Args with `id` → appended as /{id} suffix
execute('get_product', { id: 42 }) // → GET /api/v1/products/42
Sources

POS Feature Roadmap

The Question

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 features
1 Loyalty Program / Points — 68% of retailers have this
2 Gift Cards — sell, reload, redeem as payment
3 Barcode Label Printing — generate price/barcode labels
4 Dashboard Charts — visual reports with graphs
5 Server-side Pagination — handle large product catalogs
6 Low Stock Alerts — notifications when below minimum
7 Auto Reorder Suggestions — POs based on reorder points

Medium Priority

9 features
8 Layaway / Apartado — partial payments, product hold
9 Returns with original sale reference
10 Employee Time Clock — clock in/out
11 Accounting Integration — QuickBooks/Xero export
12 Email Receipts — digital receipt to customer
13 Gross Margin Report — by product/dept/store
14 Shrinkage Report — inventory loss tracking
15 Sell-Through Rate — sell speed vs received stock
16 Customer Purchase History

Low Priority

4 features
17 Multi-currency support
18 Employee Scheduling
19 Mobile POS optimization
20 Public API for external integrations

// 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:

Server-side pagination (8 APIs + 8 views)
Chart.js in 4 report views
Security audit (rate limiting, CORS, nginx)
Store config system (16 configs, 5 modules)
User-store-role assignment UI
Store provisioning UI (tokens)
Specials/promotions (CRUD + auto-apply)
POS discount (cart-wide + per-item)
Pre-validate sales before payment
Version check + force refresh
Dark mode (PrimeVue CSS variables)
All views responsive (mobile-friendly)
Rule Engine package (@pos/rule-engine) — before/after hooks, 26 tests
Sales, inventory, and transfer rules registered in store-server + hq-server
1,128 tests total (386 HQ + 193 store + 248 Vue + 26 rule-engine + 211 packages)
Sources

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