Exchange Rates Module
Overview
The exchange-rates module manages foreign exchange (FX) rates scoped to a business. Each rate maps a source currency to a target currency with a numeric multiplier and an optional validity window (validFrom / validTo).
This is used by multi-currency features (sales, accounts payable/receivable, invoicing) to convert amounts between currencies at the rate effective on a given date.
Architecture
Follows hexagonal architecture:
exchange-rates/
├── exchange-rates.module.ts # NestJS module
├── application/
│ └── exchange-rates.service.ts # Use cases
├── domain/
│ └── exchange-rates-repository.domain.ts # Repository port + domain types
├── infrastructure/
│ └── exchange-rates.repository.ts # Kysely adapter
└── interfaces/
├── exchange-rates.controller.ts # HTTP endpoints
├── dtos/
│ ├── create-exchange-rate.dto.ts
│ ├── update-exchange-rate.dto.ts
│ └── exchange-rate-with-currencies-response.dto.ts
└── query/
└── paginate-exchange-rates.query.ts
Dependency injection
The repository is injected via the EXCHANGE_RATES_REPOSITORY token, allowing the service to depend on the IExchangeRatesRepository interface rather than the concrete Kysely implementation.
Domain Concepts
| Concept | Description |
|---|---|
ExchangeRate | A business-scoped FX rate between two currencies |
fromCurrencyId | Source currency (convert from) |
toCurrencyId | Target currency (convert to) |
rate | Multiplier: 1 unit of source = rate units of target |
validFrom / validTo | Effectiveness window (validTo = null means no expiration) |
isActive | Soft-delete flag |
Enriched type
ExchangeRateWithCurrencies extends the base row with joined currency fields: fromCurrencyName, fromCurrencySymbol, fromCurrencyCountryIsoCodes, and the equivalent to* fields.
API Endpoints
All endpoints require authentication (Bearer token) and are protected by RolesGuard with PolicyResource.ExchangeRate.
| Method | Path | Description |
|---|---|---|
POST | /exchange-rates | Create a new exchange rate |
GET | /exchange-rates/business/:businessId | List active rates for a business (paginated, with currency details) |
GET | /exchange-rates/:id | Get a single exchange rate by ID |
PATCH | /exchange-rates/:id | Partially update an exchange rate |
DELETE | /exchange-rates/:id | Soft-delete an exchange rate |
Example: Create exchange rate
POST /exchange-rates
{
"businessId": "550e8400-e29b-41d4-a716-446655440000",
"createdBy": "550e8400-e29b-41d4-a716-446655440001",
"fromCurrencyId": "currency-uuid-usd",
"toCurrencyId": "currency-uuid-gtq",
"rate": 7.90,
"validFrom": "2026-01-01T00:00:00Z",
"validTo": null
}
Example: List response (business-scoped)
GET /exchange-rates/business/:businessId?size=10&page=1
{
"data": [
{
"id": "uuid",
"businessId": "uuid",
"rate": 7.90,
"validFrom": "2026-01-01T00:00:00.000Z",
"validTo": null,
"fromCurrencyName": "US Dollar",
"fromCurrencySymbol": "$",
"fromCurrencyCountryIsoCodes": "USD",
"toCurrencyName": "Guatemalan Quetzal",
"toCurrencySymbol": "Q",
"toCurrencyCountryIsoCodes": "GTQ"
}
],
"total": 1,
"page": 1,
"size": 10,
"totalPages": 1
}
Database
Table: exchange_rate
| Column | Type | Notes |
|---|---|---|
id | uuid | PK, auto-generated |
business_id | uuid | FK → business, cascade |
is_active | boolean | Default true |
created_at | timestamptz | Auto |
created_by | uuid | FK → user |
updated_at | timestamptz | Nullable |
updated_by | uuid | FK → user, set null |
from_currency_id | uuid | FK → currency, cascade |
to_currency_id | uuid | FK → currency, cascade |
rate | numeric | Not null |
valid_from | timestamptz | Not null |
valid_to | timestamptz | Nullable |
Design Decisions
- Soft delete only — The API only exposes soft-delete (
isActive = false). Hard delete is not available via HTTP. - Business-scoped listing filters active rates — The
GET /business/:businessIdendpoint automatically filtersisActive = trueand joins currency details. - No bidirectional rates — Each rate is unidirectional. To convert in the opposite direction, a separate rate record is needed (or the consumer computes
1/rate).