Saltar al contenido principal

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.