Business Units of Measure Module
Overview
The Business Units of Measure module manages the association between a business and the units of measure available to it. Each business has its own catalog of units (e.g., kg, lb, unit, each) that are used across inventory, purchasing, and sales workflows.
Two default units — unit and each — are automatically linked to every new business at creation time. When a business-scoped unit of measure is created through the global units-of-measure module, it is also automatically registered here.
Domain Concepts
| Concept | Description |
|---|---|
| Business Unit of Measure | A join record linking a business to a unitOfMeasure. Represents which units are available within a business. |
| unitOfMeasureId | UUID referencing a record in the unit_of_measure table. |
| businessId | Scopes each record to a specific business (multi-tenancy). |
| isActive | When false the unit has been soft-deleted and is no longer available for selection in new transactions. |
Architecture
This module follows Hexagonal Architecture (Ports & Adapters).
business-units-of-measure/
├── business-units-of-measure.module.ts
├── application/
│ ├── business-units-of-measure.service.ts # Use cases + event listeners
│ └── events/
│ └── on-delete-business-unit-of-measure.event.ts # Domain event (consumed by units-of-measure)
├── domain/
│ └── business-units-of-measure-repository.domain.ts # Repository port + sortable keys
├── infrastructure/
│ └── business-units-of-measure.repository.ts # Kysely adapter
└── interfaces/
├── business-units-of-measure.controller.ts # HTTP adapter
├── dtos/
│ ├── create-business-unit-of-measure.dto.ts
│ └── update-business-unit-of-measure.dto.ts
└── query/
└── paginate-business-units-of-measure.query.ts
Layer responsibilities
| Layer | File | Role |
|---|---|---|
| Domain | business-units-of-measure-repository.domain.ts | Defines IBusinessUnitsOfMeasureRepository port and sortableBusinessUnitOfMeasureKeys. No framework dependencies. |
| Application | business-units-of-measure.service.ts | Orchestrates CRUD use cases; listens to OnCreateBusinessEvent and OnUnitOfMeasureCreateEvent to auto-provision records. |
| Infrastructure | business-units-of-measure.repository.ts | Implements the domain port using Kysely. Uses soft-delete (sets isActive: false) instead of hard-delete. |
| Interfaces | business-units-of-measure.controller.ts | Maps HTTP requests to service calls; handles error propagation. |
Dependency rule
interfaces → application → domain ← infrastructure
The domain defines what it needs (IBusinessUnitsOfMeasureRepository, BUSINESS_UNITS_OF_MEASURE_REPOSITORY token, BusinessUnitOfMeasureSortKey, and sortableBusinessUnitOfMeasureKeys). Infrastructure implements the port. The service depends only on the domain interface via @Inject(BUSINESS_UNITS_OF_MEASURE_REPOSITORY) — never on the concrete repository class.
Event Integrations
Consumed events
| Event | Source module | Action |
|---|---|---|
OnCreateBusinessEvent | businesses | Auto-creates unit and each records for the new business. |
OnUnitOfMeasureCreateEvent | units-of-measure | If a UOM is created with a businessId, auto-creates the association for that business. |
Published events
| Event | Trigger | Consumers |
|---|---|---|
OnDeleteBusinessUnitOfMeasureEvent | Soft-delete of a business UOM | units-of-measure service (handles cascading cleanup) — currently not emitted; infrastructure is in place for future activation. |
Database
Table: business_unit_of_measure
| Column | Type | Notes |
|---|---|---|
id | UUID | PK, auto-generated |
business_id | UUID | FK → business(id) |
unit_of_measure_id | UUID | FK → unit_of_measure(id) |
is_active | BOOLEAN | Default: true; false = soft-deleted |
created_at | TIMESTAMPTZ | Auto-set |
created_by | UUID | FK → user(id) |
updated_at | TIMESTAMPTZ | Nullable |
updated_by | UUID | Nullable, FK → user(id) |
API Endpoints
Base path: /business-units-of-measure
| Method | Path | Description | Auth |
|---|---|---|---|
POST | / | Link a unit of measure to a business | Required |
GET | / | List (paginated, searchable) | Required |
GET | /:id | Get a single record by UUID | Required |
PATCH | /:id | Partially update a record | Required |
PATCH | /disable-uom/:id | Soft-delete (disable) a record | Required |
All endpoints are protected by RolesGuard with the BusinessUnitOfMeasure CASL resource. Fine-grained PermissionAction (Create / Read / Update / Delete) is enforced per endpoint.
Create — POST /business-units-of-measure
Request body:
{
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"unitOfMeasureId": "550e8400-e29b-41d4-a716-446655440010",
"isActive": true,
"createdBy": "550e8400-e29b-41d4-a716-446655440000"
}
Response: 201 — the created record.
List — GET /business-units-of-measure
Query parameters:
| Param | Type | Description |
|---|---|---|
businessId | UUID | Filter to a specific business |
search | string | Partial match on unit name, description, or abbreviation |
page | number | Page number (default: 1) |
size | number | Page size (default: 10; 0 = all) |
Response: 200 — IOffsetPagination<SelectableBusinessUnitOfMeasure>.
Get by ID — GET /business-units-of-measure/:id
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | Yes | Scopes lookup to the business |
Response: 200 — single record, 404 if not found.
Update — PATCH /business-units-of-measure/:id
Request body (all fields optional except businessId and updatedBy):
{
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"isActive": true,
"updatedBy": "550e8400-e29b-41d4-a716-446655440000"
}
Response: 200 — updated record.
Disable — PATCH /business-units-of-measure/disable-uom/:id
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | Yes | Scopes the operation to the business |
userId | UUID | Yes | Audit trail — who performed the disable |
Response: 200 — no body.
Design Decisions
Soft-delete instead of hard-delete
Units of measure may be referenced by historical transactions. Hard-deleting them would break referential integrity. Setting isActive: false preserves the data while preventing the unit from being selected in new transactions.
disable-uom/:id endpoint naming
The disable operation is a PATCH (not DELETE) because it modifies an existing record rather than removing it. The /disable-uom/ prefix avoids route ambiguity with PATCH /:id (general update).
sortableBusinessUnitOfMeasureKeys is empty
The association table itself has no user-facing sortable fields — sorting should be performed via the related unitOfMeasure join fields (name, abbreviation). An empty array is intentional and means the orderBy query parameter is a no-op. Future sortable fields can be added directly in domain/business-units-of-measure-repository.domain.ts.
Auto-provisioning via events
Rather than requiring callers to manually create associations, the module subscribes to OnCreateBusinessEvent to ensure every business always starts with a minimum viable UOM catalog. This keeps the creation flow side-effect-free for the caller.
Bruno API Collection
Collection files are located at:
api-client/flowpos/collections/business-units-of-measure/
| File | Request |
|---|---|
list business units of measure.yml | GET / (list) |
create business unit of measure.yml | POST / (create) |
get business unit of measure by id.yml | GET /:id |
update business unit of measure.yml | PATCH /:id (update) |
disable business unit of measure.yml | PATCH /disable-uom/:id (disable) |
Use the {{businessUnitOfMeasureId}} global environment variable (set automatically by the POST response script) when running PATCH/disable requests.