Skip to main content

Currencies and Exchange Rates

The currencies module provides comprehensive multi-currency and exchange rate management with automatic fetching from external providers. It's built on a flexible, extensible architecture that supports custom rate providers and multiple sourcing strategies.

Architecture Overview​

┌─────────────────────────┐
│ ExchangeRateService │ ← High-level, business-focused API
│ (Get rates with │ - Database-first retrieval
│ automatic fetching) │ - Automatic provider fallback
└───────────┬─────────────┘ - Daily fallback (up to 30 days)
│
▼
┌─────────────────────────┐
│ RateFetchingService │ ← Low-level, provider orchestration
│ (Fetch and store) │ - Manages provider lifecycle
└───────────┬─────────────┘ - Batch processing
│ - Error tracking
▼
┌─────────────────────────┐
│ Rate Providers │ ← External data sources
│ (NBP, Raiffeisen, │ - Pluggable architecture
│ Custom providers) │ - Standardized interface
└─────────────────────────┘

Database Schema​

Currency Entity​

Stores currency metadata with multi-tenant scoping:

@Entity()
export class Currency {
@PrimaryKey()
id: string

@Property()
code: string // ISO 4217 code (e.g., "USD")

@Property()
name: string

@Property({ nullable: true })
symbol?: string

@Property({ default: 2 })
decimalPlaces: number

@Property({ nullable: true })
thousandSeparator?: string

@Property({ nullable: true })
decimalSeparator?: string

@Property({ default: false })
isBase: boolean // One base currency per organization

@Property()
tenantId: string

@Property()
organizationId: string

@Property()
isActive: boolean

@Property()
createdAt: Date

@Property({ onUpdate: () => new Date() })
updatedAt: Date
}

Key indexes:

  • (tenant_id, organization_id, code) – Unique constraint, ensures one currency per code per org
  • (tenant_id, organization_id, is_base) – Fast base currency lookup
  • (tenant_id, organization_id, is_active) – Active currency queries

ExchangeRate Entity​

Stores directional exchange rates with source attribution:

@Entity()
export class ExchangeRate {
@PrimaryKey()
id: string

@Property()
fromCurrencyCode: string

@Property()
toCurrencyCode: string

@Property({ columnType: 'decimal(18,8)' })
rate: string // High precision decimal

@Property()
date: Date

@Property()
source: string // Provider identifier (e.g., "NBP", "Raiffeisen", "manual")

@Property({ nullable: true })
type?: 'buy' | 'sell' // Bank's perspective

@Property()
tenantId: string

@Property()
organizationId: string

@Property()
isActive: boolean

@Property()
createdAt: Date

@Property({ onUpdate: () => new Date() })
updatedAt: Date
}

Key indexes:

  • (tenant_id, organization_id, from_currency_code, to_currency_code, date, source) – Fast pair lookups
  • (tenant_id, organization_id, date) – Date-based queries
  • (tenant_id, organization_id, source) – Provider-based filtering

CurrencyFetchConfig Entity​

Tracks provider configuration and sync status:

@Entity()
export class CurrencyFetchConfig {
@PrimaryKey()
id: string

@Property()
provider: string // Provider identifier

@Property({ default: true })
enabled: boolean

@Property({ nullable: true })
config?: any // JSON config for provider

@Property({ nullable: true })
lastSyncAt?: Date

@Property({ nullable: true })
lastSyncCount?: number

@Property()
tenantId: string

@Property()
organizationId: string

@Property()
createdAt: Date

@Property({ onUpdate: () => new Date() })
updatedAt: Date
}

Service Layer​

ExchangeRateService​

High-level API for retrieving exchange rates with automatic fetching and fallback.

Dependency Injection​

import type { ExchangeRateService } from '@saasframe/modules/currencies/services/exchangeRateService'

const exchangeRateService = container.resolve<ExchangeRateService>('exchangeRateService')

Single Rate Retrieval​

const result = await exchangeRateService.getRate({
fromCurrencyCode: 'USD',
toCurrencyCode: 'EUR',
date: new Date('2024-01-15'),
scope: {
tenantId: 'tenant-123',
organizationId: 'org-456',
},
options: {
maxDaysBack: 30, // Look back up to 30 days (default)
autoFetch: true, // Fetch from providers if not found (default)
},
})

// Result structure
interface RateResult {
rates: Array<{
rate: string // Decimal string (e.g., "1.12345678")
source: string // Provider or "manual"
type?: 'buy' | 'sell' // Optional rate type
date: Date // Rate date
}>
actualDate: Date // Date used (may be earlier than requested)
error?: Error // Only in batch operations
}

if (result.rates.length > 0) {
const rate = parseFloat(result.rates[0].rate)
const convertedAmount = 100 * rate
}

Batch Retrieval​

const results = await exchangeRateService.getRates({
pairs: [
{ fromCurrencyCode: 'USD', toCurrencyCode: 'EUR' },
{ fromCurrencyCode: 'GBP', toCurrencyCode: 'PLN' },
{ fromCurrencyCode: 'EUR', toCurrencyCode: 'JPY' },
],
date: new Date(),
scope: { tenantId, organizationId },
})

// Results is a Map<string, RateResult>
for (const [key, result] of results.entries()) {
if (result.error) {
console.error(`Failed to get ${key}: ${result.error.message}`)
} else if (result.rates.length > 0) {
console.log(`${key}: ${result.rates[0].rate}`)
} else {
console.log(`${key}: No rates found`)
}
}

Daily Fallback​

If a rate isn't found for the requested date, the service automatically:

  1. Checks the database
  2. Fetches from providers if autoFetch=true
  3. Recursively checks previous days (up to maxDaysBack)
  4. Returns actualDate to indicate which date was used

Date Validation​

  • Allowed: Today and all historical dates
  • Rejected: Future dates (throws error)
// ✅ Valid
await exchangeRateService.getRate({
fromCurrencyCode: 'USD',
toCurrencyCode: 'EUR',
date: new Date(), // Today
scope: { tenantId, organizationId },
})

// ❌ Throws error
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
await exchangeRateService.getRate({
fromCurrencyCode: 'USD',
toCurrencyCode: 'EUR',
date: tomorrow,
scope: { tenantId, organizationId },
})

RateFetchingService​

Lower-level service for fetching and storing rates from providers.

Dependency Injection​

import type { RateFetchingService } from '@saasframe/modules/currencies/services/rateFetchingService'

const rateFetchingService = container.resolve<RateFetchingService>('rateFetchingService')

Fetch Rates​

const result = await rateFetchingService.fetchRatesForDate(
new Date('2024-01-15'),
{ tenantId: 'tenant-123', organizationId: 'org-456' }
)

// Result structure
interface FetchResult {
totalFetched: number
byProvider: Record<string, number>
errors: Array<{
provider: string
error: Error
}>
}

console.log(`Fetched ${result.totalFetched} rates`)
console.log(`NBP: ${result.byProvider.NBP || 0}`)
console.log(`Raiffeisen: ${result.byProvider.Raiffeisen || 0}`)

When to Use Each Service​

Use CaseService
Business logic needing ratesExchangeRateService
Currency conversionsExchangeRateService
Scheduled/batch fetchingRateFetchingService
Admin operationsRateFetchingService
Automatic fallback neededExchangeRateService
Explicit control over fetchingRateFetchingService

Rate Providers​

Built-in Providers​

NBP (National Bank of Poland)​

import { NBPProvider } from '@saasframe/modules/currencies/services/providers/nbp'

const provider = new NBPProvider()
  • Currencies: ~13 major currencies (USD, EUR, GBP, CHF, etc.)
  • Rate types: Bid (buy) and Ask (sell)
  • Data source: National Bank of Poland Table C
  • Updates: Daily, published by central bank
  • API: Public REST API

Raiffeisen Bank Polska​

import { RaiffeisenProvider } from '@saasframe/modules/currencies/services/providers/raiffeisen'

const provider = new RaiffeisenProvider()
  • Currencies: EUR, USD, CHF, GBP
  • Rate types: Buy and Sell
  • Data source: Web scraping from public rate tables
  • Updates: Multiple times per day (intraday rates)
  • Method: HTML parsing

Creating Custom Providers​

Implement the RateProvider interface:

import type { RateProvider, RateProviderResult } from '@saasframe/modules/currencies/services/providers/base'

export class MyCustomProvider implements RateProvider {
name = 'MyProvider'

async fetchRates(
date: Date,
scope: { tenantId: string; organizationId: string }
): Promise<RateProviderResult[]> {
// Fetch rates from your source
const rates = await this.fetchFromApi(date)

// Transform to standard format
return rates.map(r => ({
fromCurrencyCode: r.from,
toCurrencyCode: r.to,
rate: r.value.toString(),
source: this.name,
type: r.type, // 'buy', 'sell', or undefined
}))
}

private async fetchFromApi(date: Date) {
// Your implementation
}
}

Register Custom Provider​

// In your module's di.ts
import { asClass } from 'awilix'
import type { AppContainer } from '@/lib/di/container'
import { MyCustomProvider } from './providers/myCustomProvider'

export function register(container: AppContainer) {
const rateFetchingService = container.resolve('rateFetchingService')

// Register your provider
rateFetchingService.registerProvider(new MyCustomProvider())
}

REST API​

Currencies​

GET /api/currencies List currencies
POST /api/currencies Create currency
PUT /api/currencies/:id Update currency
DELETE /api/currencies/:id Delete currency

Query parameters for GET:

  • page, limit – Pagination
  • search – Filter by code or name
  • isActive – Filter by active status
  • isBase – Filter base currency

Exchange Rates​

GET /api/exchange-rates List rates
POST /api/exchange-rates Create rate
PUT /api/exchange-rates/:id Update rate
DELETE /api/exchange-rates/:id Delete rate

Query parameters for GET:

  • page, limit – Pagination
  • fromCurrencyCode – Filter by source currency
  • toCurrencyCode – Filter by target currency
  • date – Filter by date
  • dateFrom, dateTo – Date range
  • source – Filter by provider
  • type – Filter by rate type (buy/sell)

Fetch Configuration​

GET /api/fetch-configs List provider configs
POST /api/fetch-configs Enable provider
PUT /api/fetch-configs/:id Update config
DELETE /api/fetch-configs/:id Delete config

Trigger Manual Fetch​

POST /api/fetch-rates Fetch rates for date/range

Request body:

{
"date": "2024-01-15",
"dateFrom": "2024-01-01", // Optional: for range
"dateTo": "2024-01-31" // Optional: for range
}

CLI Commands​

Fetch Rates​

# Fetch for today
yarn saasframe currencies fetch-rates

# Fetch for specific date
yarn saasframe currencies fetch-rates --date 2024-01-15

# Fetch for date range
yarn saasframe currencies fetch-rates --from 2024-01-01 --to 2024-01-31

List Providers​

yarn saasframe currencies list-providers

Shows all registered providers, their status, and available currencies.

Seed Example Currencies​

yarn saasframe currencies seed

Creates example currencies (USD, EUR, PLN, GBP) for testing.

Access Control (ACL)​

The module defines these features for RBAC:

export const features = [
{
id: 'currencies.view',
label: 'View currencies',
description: 'Can view currency list and details',
},
{
id: 'currencies.manage',
label: 'Manage currencies',
description: 'Can create, update, and delete currencies',
},
{
id: 'currencies.rates.view',
label: 'View exchange rates',
description: 'Can view exchange rates',
},
{
id: 'currencies.rates.manage',
label: 'Manage exchange rates',
description: 'Can create, update, and delete exchange rates',
},
{
id: 'currencies.fetch.view',
label: 'View fetch configuration',
description: 'Can view provider configuration',
},
{
id: 'currencies.fetch.manage',
label: 'Manage fetch configuration',
description: 'Can configure rate providers',
},
]

Use in API routes:

export const GET = handler({
requireAuth: true,
requireFeatures: ['currencies.view'],
async handle(req, res) {
// Your handler
},
})

export const POST = handler({
requireAuth: true,
requireFeatures: ['currencies.manage'],
async handle(req, res) {
// Your handler
},
})

CQRS Commands​

The module uses command/query separation:

import { createCurrency, updateCurrency, deleteCurrency } from '@saasframe/modules/currencies/commands/currencies'
import { createExchangeRate, updateExchangeRate, deleteExchangeRate } from '@saasframe/modules/currencies/commands/exchange-rates'

// Create currency
await createCurrency(
{
code: 'USD',
name: 'US Dollar',
symbol: '$',
decimalPlaces: 2,
isBase: true,
},
{ tenantId, organizationId },
container
)

// Create exchange rate
await createExchangeRate(
{
fromCurrencyCode: 'USD',
toCurrencyCode: 'EUR',
rate: '0.92',
date: new Date(),
source: 'manual',
type: 'buy',
},
{ tenantId, organizationId },
container
)

Data Validation​

All inputs are validated using Zod schemas:

import { currencyCreateSchema, exchangeRateCreateSchema } from '@saasframe/modules/currencies/data/validators'

// Currency validation
const currencyData = currencyCreateSchema.parse({
code: 'USD',
name: 'US Dollar',
symbol: '$',
// ...
})

// Exchange rate validation
const rateData = exchangeRateCreateSchema.parse({
fromCurrencyCode: 'USD',
toCurrencyCode: 'EUR',
rate: '0.92',
// ...
})

Key validation rules:

  • Currency codes: 3 uppercase letters (ISO 4217)
  • Rate values: Positive decimals, up to 8 decimal places
  • Source: 2-50 alphanumeric characters, hyphens, underscores
  • Dates: Truncated to minute precision

Example: Currency Conversion Utility​

import type { ExchangeRateService } from '@saasframe/modules/currencies/services/exchangeRateService'

export async function convertCurrency(
amount: number,
fromCode: string,
toCode: string,
date: Date,
scope: { tenantId: string; organizationId: string },
options?: {
preferredProvider?: string
rateType?: 'buy' | 'sell'
}
): Promise<number | null> {
const exchangeRateService = container.resolve<ExchangeRateService>('exchangeRateService')

const result = await exchangeRateService.getRate({
fromCurrencyCode: fromCode,
toCurrencyCode: toCode,
date,
scope,
})

if (result.rates.length === 0) {
return null // No rate available
}

// Filter by preferences
let selectedRate = result.rates[0]

if (options?.preferredProvider) {
const providerRate = result.rates.find(r => r.source === options.preferredProvider)
if (providerRate) selectedRate = providerRate
}

if (options?.rateType) {
const typeRate = result.rates.find(r => r.type === options.rateType)
if (typeRate) selectedRate = typeRate
}

return amount * parseFloat(selectedRate.rate)
}

// Usage
const eurAmount = await convertCurrency(
100,
'USD',
'EUR',
new Date(),
{ tenantId: 'tenant-123', organizationId: 'org-456' },
{ preferredProvider: 'NBP', rateType: 'sell' }
)

Module Configuration​

Enable in apps/saasframe/src/modules.ts:

export const modules: ModuleConfig[] = [
// ... other modules
{ id: 'currencies', from: '@saasframe/core' },
]

The module is fully self-contained:

  • Auto-discovered routes and pages
  • Automatic DI registration
  • Self-managed migrations
  • Isolated database schema
  • Multi-tenant by design

Migration Generation​

Generate migrations after schema changes:

yarn generate
yarn db:generate

Migrations are written to:

packages/core/src/modules/currencies/migrations/

Apply migrations:

yarn db:migrate

See Also​