Inventory Ledgers
Overview
The Inventory Ledgers module records every stock movement as an immutable ledger entry. It functions as the single source of truth for inventory event history, supporting purchases, sales, adjustments, transfers, returns, production, damage reports, and reservations.
Every ledger entry captures:
- What moved (product, variant, quantity, batch/serial)
- Where it moved (business, location, stock bucket)
- Why it moved (source type, source document, movement type)
- How much it cost (unit cost, unit price, amounts in both transaction and base currencies)
Architecture
inventory-ledgers/
├── inventory-ledgers.module.ts # NestJS module
├── domain/
│ └── inventory-ledgers-repository.domain.ts # Port interface + sortable keys
├── application/
│ └── inventory-ledgers.service.ts # Use cases
├── infrastructure/
│ └── inventory-ledgers.repository.ts # Kysely adapter
└── interfaces/
├── inventory-ledgers.controller.ts # REST controller
├── dtos/
│ ├── create-inventory-ledger.dto.ts
│ └── update-inventory-ledger.dto.ts
└── query/
└── paginate-inventory-ledgers.query.ts
Layer Responsibilities
| Layer | Responsibility |
|---|---|
| Domain | Repository interface (port), sortable field definitions. Framework-agnostic. |
| Application | Use cases: create (single + batch), paginate, get by ID, update, delete. Idempotency key auto-generation. |
| Infrastructure | Kysely implementation of the repository port. Handles SQL queries, pagination, search, filtering. |
| Interfaces | REST controller (5 endpoints), DTOs with validation, query objects. |
Dependency Injection
The repository is injected via the INVENTORY_LEDGERS_REPOSITORY symbol token, allowing the service to depend on the domain interface rather than the concrete Kysely implementation.
Database
Table: inventory_ledger
Key Columns
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated |
businessId | uuid (FK) | Multi-tenancy scope |
locationId | uuid (FK) | Where the movement occurred |
productId | uuid (FK) | Product affected |
sourceType | string | Document type (purchase, sale, adjustment, etc.) |
sourceId | uuid | Source document reference |
sourceLineId | uuid | Source line item reference |
quantity | decimal | Quantity moved (positive = in, negative = out) |
unitCost / unitPrice | decimal | Per-unit financials |
amount / baseAmount | decimal | Total in transaction / base currency |
cost / baseCost | decimal | Total cost in transaction / base currency |
movementType | enum | sale_out, return_in, adjustment, transfer_out, transfer_in, damage_mark, damage_restore, quarantine_mark, quarantine_release, reservation_hold, reservation_release |
stockBucket | enum | on_hand, damaged, quarantine, reserved |
batchNumber / serialNumber | string | Traceability |
idempotencyKey | string (unique) | Duplicate prevention |
saleId / saleLineIndex | uuid / int | Return/exchange tracking |
Indexes
idx_inventory_ledger_business(businessId)idx_inventory_ledger_location(locationId)idx_inventory_ledger_sale(saleId)idx_inventory_ledger_sale_line(saleId, saleLineIndex)idx_inventory_ledger_movement_type(movementType)idx_inventory_ledger_stock_bucket(stockBucket)- Unique partial index on (businessId, serialNumber, movementType) WHERE movementType = 'return_in'
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /inventory-ledgers | Create a ledger entry |
GET | /inventory-ledgers | List with pagination, search, filtering |
GET | /inventory-ledgers/:id | Get by ID |
PATCH | /inventory-ledgers/:id | Partial update (requires updatedBy) |
DELETE | /inventory-ledgers/:id | Delete entry |
Query Parameters (GET list)
| Param | Type | Description |
|---|---|---|
businessId | UUID | Filter by business |
locationId | UUID | Filter by location |
productId | UUID | Filter by product |
search | string | Full-text search (sourceType, batchNumber, serialNumber, locationName, productName) |
page | number | Page number (default: 1) |
size | number | Page size (default: 10, 0 = no limit) |
orderBy | string | Sort field |
order | string | asc or desc |
Consumers
This module is used by multiple other modules via InventoryLedgersService:
| Module | Usage |
|---|---|
| data-import | Batch creates ledger rows for opening inventory imports |
| production-runs | Tracks material consumption and output |
| retail-reservations | Records reservation_hold / reservation_release movements |
| retail-return | Records return_in movements and restocking |
| stock-count | Records adjustment movements from inventory counts |
| damage-reports | Records damage_mark / damage_restore / quarantine movements |
| inventories | Primary consumer — coordinates stock quantities with ledger |
Idempotency
Every ledger entry has a unique idempotencyKey:
- Single creates (via API): auto-generated UUID if not provided by client
- Batch creates (via
createByRowsWithTransaction): usesON CONFLICT (idempotencyKey) DO NOTHINGto silently skip duplicates
Design Decisions
- Immutable-first: Ledger entries are append-only in practice. Update/delete endpoints exist for admin corrections but are not part of normal business flows.
- Denormalized names:
locationNameandproductNameare stored directly for historical accuracy and offline reporting (the product or location may be renamed later). - Dual currency: All financial fields exist in both transaction currency (
amount,cost) and base/reporting currency (baseAmount,baseCost). - Movement type + stock bucket: Added to support return/exchange, damage, and reservation workflows. Nullable for backward compatibility with pre-existing entries.