POS Research Hub

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

Last updated: March 16, 2026
4
Research Topics
12
Sources Cited
3
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

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

Sync Infrastructure Redesign

The Question

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

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. Worksheets and edits silently fail to reach stores.

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. Worksheet Apply changes don't update it either.

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 — High

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

// Migration Strategy

The migration must be done carefully — stores will need a full re-sync after the change.

Step 1: Global version counter for HQ products

Create/use sequence sync_seq_products. When any product is modified, increment the global sequence and stamp the row.

Step 2: Apply to all synced entities

Departments, tax_rates, tax_groups, suppliers, tenders, users, roles, permissions — each entity type gets its own global sequence.

Step 3: Store pushes product state

When store modifies a product locally, add to outbox: entity_type: 'product_stock' with full state.

Step 4: Remove HQ quantity calculations

Remove updateStoreDynamic calls from sync push handler. store_product_dynamic.quantity only comes from what the store reports.

Step 5: Worksheet Apply changes

Worksheet Apply bumps the GLOBAL syncVersion. Store receives via pull, updates local, pushes state back, HQ updates store_product_dynamic.

Step 6: Re-stamp existing rows

Sequential global versions for all existing rows. Force full re-sync on all stores (reset lastSyncVersion to 0).

Step 7: Deploy and verify

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