Business Units Conversion Module
Overview
The Business Units Conversion module manages conversion factors between units of measure within a business. It enables the system to express quantities in different units across inventory, purchasing, and sales workflows (e.g., purchasing goods in pounds but tracking stock in kilograms).
Domain Concepts
| Concept | Description |
|---|---|
| Conversion Rule | A directional mapping from one unit (fromUnit) to another (toUnit) with a multiplication factor (conversionFactor). |
| fromUnit / toUnit | UUIDs referencing records in the unit_of_measure table. |
| conversionFactor | A positive decimal that, when multiplied by a quantity in fromUnit, yields the equivalent quantity in toUnit. |
| businessId | Scopes each rule to a specific business (multi-tenancy). |
| isActive | Flags whether the rule is currently applicable. |
Formula:
quantity_in_toUnit = quantity_in_fromUnit × conversionFactor
Example: 1 lb × 0.453592 = 0.453592 kg
Architecture
This module follows Hexagonal Architecture (Ports & Adapters).
business-units-conversion/
├── business-units-conversion.module.ts
├── application/
│ └── business-units-conversion.service.ts # Use cases (orchestration)
├── domain/
│ └── business-units-conversion-repository.domain.ts # Repository port + sortable keys + injection token
├── infrastructure/
│ └── business-units-conversion.repository.ts # Kysely adapter
└── interfaces/
├── business-units-conversion.controller.ts # HTTP adapter
├── dtos/
│ ├── create-business-unit-conversion.dto.ts
│ └── update-business-unit-conversion.dto.ts
└── query/
└── paginate-business-units-conversion.query.ts
Layer responsibilities
| Layer | File | Role |
|---|---|---|
| Domain | business-units-conversion-repository.domain.ts | Defines the IBusinessUnitsConversionRepository port, the BUSINESS_UNITS_CONVERSION_REPOSITORY injection token, and the allowed sortable fields. No framework dependencies. |
| Application | business-units-conversion.service.ts | Orchestrates CRUD use cases; generates paginated responses. Depends only on the domain interface via @Inject(BUSINESS_UNITS_CONVERSION_REPOSITORY). |
| Infrastructure | business-units-conversion.repository.ts | Implements the domain port using Kysely. All queries are scoped by businessId for multi-tenancy isolation. Runs a transaction for findAll to fetch count and results atomically. |
| Interfaces | business-units-conversion.controller.ts | Maps HTTP requests to service calls; handles error propagation. Protected by RolesGuard with PolicyResource.BusinessUnitConversion. |
Dependency rule
interfaces → application → domain ← infrastructure
The domain defines what it needs (IBusinessUnitsConversionRepository). Infrastructure implements it. The module wires the concrete repository via provide/useClass with the BUSINESS_UNITS_CONVERSION_REPOSITORY Symbol token. The application and interfaces layers both depend inward on the domain; the domain depends on nothing inside the application.
Database
Table: business_unit_conversion
| Column | Type | Notes |
|---|---|---|
id | UUID | PK, auto-generated |
business_id | UUID | FK → business(id) |
from_unit_id | UUID | FK → unit_of_measure(id) |
to_unit_id | UUID | FK → unit_of_measure(id) |
conversion_factor | NUMERIC | Must be ≥ 0 |
description | VARCHAR | Nullable |
is_active | BOOLEAN | Default: true |
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-conversion
| Method | Path | Description | Auth |
|---|---|---|---|
POST | / | Create a new conversion rule | Required |
GET | / | List all rules (paginated, searchable) | Required |
GET | /:id?businessId= | Get a single rule by UUID | Required |
PATCH | /:id | Partially update a rule | Required |
DELETE | /:id?businessId= | Delete a rule | Required |
All endpoints are protected by RolesGuard with the BusinessUnitConversion CASL resource. Fine-grained PermissionAction (Create / Read / Update / Delete) is enforced per endpoint.
Create — POST /business-units-conversion
Request body:
{
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"fromUnitId": "550e8400-e29b-41d4-a716-446655440010",
"toUnitId": "550e8400-e29b-41d4-a716-446655440011",
"conversionFactor": 0.453592,
"description": "Conversion from pounds to kilograms",
"isActive": true,
"createdBy": "550e8400-e29b-41d4-a716-446655440000"
}
Response: 201 — the created record.
List — GET /business-units-conversion
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | No | Filter by business |
search | string | No | Partial match on description |
page | number | No | Page number (default: 1) |
size | number | No | Page size (default: 10; 0 = all) |
orderBy | string | No | description |
order | string | No | asc or desc |
Response: 200 — IOffsetPagination<SelectableBusinessUnitConversion>.
Get by ID — GET /business-units-conversion/:id
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | Yes | Business the record belongs to |
Response: 200 — single record, 404 if not found.
Update — PATCH /business-units-conversion/:id
Request body (all fields optional except updatedBy):
{
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"conversionFactor": 0.45,
"updatedBy": "550e8400-e29b-41d4-a716-446655440000"
}
Response: 200 — updated record.
Delete — DELETE /business-units-conversion/:id
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
businessId | UUID | Yes | Business the record belongs to |
Response: 200 — no body.
Design Decisions
Interface-based injection
The domain layer exports a BUSINESS_UNITS_CONVERSION_REPOSITORY Symbol token alongside the IBusinessUnitsConversionRepository interface. The module wires the concrete BusinessUnitsConversionRepository via provide/useClass, and the service injects the interface via @Inject(BUSINESS_UNITS_CONVERSION_REPOSITORY). This keeps the application layer decoupled from the infrastructure implementation and enables adapter swaps and testing with mocks.
Multi-tenancy scoping
All repository queries (findAll, findById, update, delete) are scoped by businessId to enforce tenant isolation. The findAll method accepts businessId via the filter object; findById, update, and delete require it as a parameter via IRepositoryActionByIdWithBusiness / IRepositoryWithBusinessUpdate.
Sortable keys defined in domain, not interfaces
sortableBusinessUnitConversionKeys is declared in business-units-conversion-repository.domain.ts and re-exported from the query file. This keeps the domain as the single source of truth about what fields are query-able.
description is optional
The database schema declares description as nullable. The DTO correctly marks it @IsOptional() so conversion rules without a description are valid.
conversionFactor must be ≥ 0
A @Min(0) constraint is enforced at the DTO level. Zero is technically allowed (a degenerate no-conversion edge case), but negative factors are rejected as meaningless.
Bidirectionality is not automatic
A rule from A → B does not create an implicit B → A rule. Create a second rule with the inverse factor (1 / conversionFactor) if the reverse direction is needed.
Bruno API Collection
Collection files are located at:
api-client/flowpos/collections/business-units-conversion/
| File | Request |
|---|---|
business units of conversion.yml | GET / (list) — includes businessId query param |
business unit of conversion.yml | POST / (create) |
business unit of conversion by Id.yml | GET /:id — requires businessId query param |
update business unit of conversion.yml | PATCH /:id |
delete business unit of conversion.yml | DELETE /:id — requires businessId query param |
Use the {{businessUnitConversionId}} global environment variable (set automatically by the POST response script) when running PATCH/DELETE requests.