Business Currencies
Overview
The business-currencies module manages the many-to-many association between a Business and the Currencies it accepts. Each record on the businessCurrency table captures:
| Column | Description |
|---|---|
id | UUID primary key of the join record |
businessId | FK -> business.id |
currencyId | FK -> currency.id |
isDefault | Whether this is the business's default currency (only one at a time) |
isActive | Soft-delete flag (false = disabled) |
createdBy / updatedBy | User audit trail |
A business always has exactly one isDefault = true currency. All currency amounts in orders and sales are expressed in the business's default currency unless an explicit exchange rate is applied.
Architecture
This module follows Hexagonal Architecture (Ports & Adapters):
business-currencies/
├── domain/
│ ├── business-currencies-repository.domain.ts <- port interface + DI token
│ └── business-currencies-service.domain.ts <- parameter interfaces
├── application/
│ ├── business-currencies.service.ts <- use-case orchestrator
│ └── events/
│ └── on-delete-business-currency.event.ts <- event payload class
├── infrastructure/
│ └── business-currencies.repository.ts <- Kysely adapter (implements port)
└── interfaces/
├── business-currencies.controller.ts <- HTTP adapter
├── dtos/
│ ├── create-business-currency.dto.ts
│ ├── update-business-currency.dto.ts
│ └── set-default-currency.dto.ts
└── query/
└── paginate-business-currencies.query.ts
Dependency flow
Controller -> Service -> Repository (via BUSINESS_CURRENCIES_REPOSITORY token) -> Kysely -> PostgreSQL
The service depends on the IBusinessCurrenciesRepository interface (port), injected via @Inject(BUSINESS_CURRENCIES_REPOSITORY). The module wires the concrete BusinessCurrenciesRepository adapter at startup. The controller never touches the database directly.
Domain Concepts
Default Currency invariant
Only one businessCurrency record per business may have isDefault = true at any time. This invariant is enforced at the application layer:
createBusinessCurrencyHandlingDefault-- demotes the old default before inserting.updateBusinessCurrencyHandlingDefault-- demotes the old default before updating.setDefaultBusinessCurrency-- atomic swap of the default flag.
All three operations execute within a single database transaction.
Soft-delete
Records are never physically deleted. The disable-bc/:id endpoint sets isActive = false. The findAll query filters to isActive = true for both the count and results, ensuring consistent pagination metadata.
Event Handlers
The service listens to three domain events:
| Event | Handler | Behavior |
|---|---|---|
OnCreateBusinessEvent | handleBusinessCreateEvent | Seeds default currencies based on business.countryId |
OnCurrencyCreateEvent | handleCurrencyCreateEvent | Creates a businessCurrency link when a business-scoped currency is created |
OnCurrencyUpdateEvent | handleCurrencyUpdateEvent | Syncs isDefault flag when a currency is updated |
The service emits one event:
| Event | When | Payload |
|---|---|---|
OnDeleteBusinessCurrencyEvent | After soft-deleting a currency | The deleted record + the current default (if any) |
Country -> Currency seeding matrix
| Country | Default | Also added |
|---|---|---|
| GT (Guatemala) | Quetzal (GTQ) | USD |
| SV (El Salvador) | USD | Bitcoin |
| HN (Honduras) | Lempira (HNL) | USD |
| MX (Mexico) | Mexican Peso (MXN) | USD |
| US (United States) | USD | Bitcoin |
| (default) | Quetzal (GTQ) | USD |
API Endpoints
Base path: /business-currencies
| Method | Path | Description |
|---|---|---|
GET | / | List business currencies (paginated, sortable) |
GET | /:id | Get a single business currency by ID |
POST | / | Create a new business currency |
POST | /set-default | Atomically promote a currency to default |
PATCH | /:id | Update a business currency |
PATCH | /disable-bc/:id | Soft-delete (disable) a business currency |
All endpoints require a Firebase Bearer token.
GET /business-currencies
Returns paginated active business currencies with linked currency details.
Query parameters:
| Name | Type | Required | Description |
|---|---|---|---|
businessId | UUID | No | Filter by business |
isDefault | boolean | No | When true, returns only the default |
search | string | No | Searches currency name, symbol, ISO codes |
page | number | No | Page number (default: 1) |
size | number | No | Page size (default: 10; 0 = no limit) |
orderBy | string | No | Sort field: createdAt, isDefault |
order | string | No | Sort direction: asc or desc |
GET /business-currencies/:id
Returns a single business-currency record with the linked currency details.
Required query parameter: businessId (UUID)
Returns 404 if not found.
POST /business-currencies
Creates a new business-currency association.
Body:
{
"businessId": "uuid",
"currencyId": "uuid",
"isDefault": false,
"isActive": true,
"createdBy": "uuid"
}
If isDefault is true, the existing default is demoted atomically.
POST /business-currencies/set-default
Promotes an existing business-currency record to default.
Body:
{
"businessId": "uuid",
"businessCurrencyId": "uuid",
"updatedBy": "uuid"
}
businessCurrencyIdis theidof thebusinessCurrencyjoin record (NOT acurrency.id).
PATCH /business-currencies/:id
Updates an existing business-currency record.
Body (all fields optional except businessId and updatedBy):
{
"businessId": "uuid",
"updatedBy": "uuid",
"isDefault": true,
"isActive": true
}
If isDefault is true, the existing default is demoted atomically.
PATCH /business-currencies/disable-bc/:id
Soft-deletes a business-currency record by setting isActive = false.
Query parameters: businessId (UUID), userId (UUID)
Design Decisions
Why soft-delete instead of hard-delete?
Business currencies may be referenced in historical transaction records. Hard deletion would break referential integrity and historical reporting.
Why is userId a query parameter in /disable-bc?
This is a known limitation. Ideally userId would be extracted from the authenticated Firebase token using @FirebaseUser(). Migrating this to use the token is a tracked follow-up.
Why does businessCurrency.delete use UPDATE instead of DELETE?
See soft-delete rationale above. The method is named delete to preserve the repository port interface, but the implementation performs a soft-delete via UPDATE.
Why does the service inject DATABASE directly?
Transaction orchestration across multiple repository calls requires direct access to the Kysely database instance. This is the established pattern across 50+ services in the codebase. The repository is still injected via the BUSINESS_CURRENCIES_REPOSITORY interface token to maintain the hexagonal port/adapter boundary.