Skip to main content

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:

ColumnDescription
idUUID primary key of the join record
businessIdFK -> business.id
currencyIdFK -> currency.id
isDefaultWhether this is the business's default currency (only one at a time)
isActiveSoft-delete flag (false = disabled)
createdBy / updatedByUser 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:

EventHandlerBehavior
OnCreateBusinessEventhandleBusinessCreateEventSeeds default currencies based on business.countryId
OnCurrencyCreateEventhandleCurrencyCreateEventCreates a businessCurrency link when a business-scoped currency is created
OnCurrencyUpdateEventhandleCurrencyUpdateEventSyncs isDefault flag when a currency is updated

The service emits one event:

EventWhenPayload
OnDeleteBusinessCurrencyEventAfter soft-deleting a currencyThe deleted record + the current default (if any)

Country -> Currency seeding matrix​

CountryDefaultAlso added
GT (Guatemala)Quetzal (GTQ)USD
SV (El Salvador)USDBitcoin
HN (Honduras)Lempira (HNL)USD
MX (Mexico)Mexican Peso (MXN)USD
US (United States)USDBitcoin
(default)Quetzal (GTQ)USD

API Endpoints​

Base path: /business-currencies

MethodPathDescription
GET/List business currencies (paginated, sortable)
GET/:idGet a single business currency by ID
POST/Create a new business currency
POST/set-defaultAtomically promote a currency to default
PATCH/:idUpdate a business currency
PATCH/disable-bc/:idSoft-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:

NameTypeRequiredDescription
businessIdUUIDNoFilter by business
isDefaultbooleanNoWhen true, returns only the default
searchstringNoSearches currency name, symbol, ISO codes
pagenumberNoPage number (default: 1)
sizenumberNoPage size (default: 10; 0 = no limit)
orderBystringNoSort field: createdAt, isDefault
orderstringNoSort 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"
}

businessCurrencyId is the id of the businessCurrency join record (NOT a currency.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.