@open-mercato/core 0.6.8-develop.7100.1.fbf66fca35 → 0.6.8-develop.7101.1.bd9356828a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- [build:core] found 4355 entry points
1
+ [build:core] found 4356 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 218 entry points
4
4
  [build:core:generated] built successfully
@@ -66,11 +66,16 @@ class RateFetchingService {
66
66
  return { kind: "failed", providerSource, error: message };
67
67
  }
68
68
  }
69
+ /**
70
+ * Currencies whose rates are worth storing. Deliberately not filtered by `isActive`: that flag
71
+ * answers whether a currency may be picked for something new, which says nothing about whether
72
+ * records already denominated in it must stay convertible. Soft delete is what takes a currency
73
+ * out of rate fetching.
74
+ */
69
75
  async getExistingCurrencies(scope) {
70
76
  return this.em.find(Currency, {
71
77
  tenantId: scope.tenantId,
72
78
  organizationId: scope.organizationId,
73
- isActive: true,
74
79
  deletedAt: null
75
80
  });
76
81
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/currencies/services/rateFetchingService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { RateProvider, RateProviderResult } from './providers/base'\nimport { Currency, ExchangeRate } from '../data/entities'\n\nexport interface FetchResult {\n totalFetched: number\n byProvider: Record<string, { count: number; errors?: string[] }>\n errors: string[]\n}\n\nexport interface FetchOptions {\n providers?: string[]\n forceUpdate?: boolean\n}\n\ntype ProviderFetchOutcome =\n | { kind: 'skipped'; providerSource: string; error: string }\n | { kind: 'failed'; providerSource: string; error: string }\n | { kind: 'fetched'; providerSource: string; rates: RateProviderResult[] }\n\nconst exchangeRateKey = (\n fromCurrencyCode: string,\n toCurrencyCode: string,\n date: Date,\n source: string\n): string => `${fromCurrencyCode}|${toCurrencyCode}|${date.getTime()}|${source}`\n\nexport class RateFetchingService {\n private providers: Map<string, RateProvider>\n\n constructor(private em: EntityManager) {\n this.providers = new Map()\n }\n\n /**\n * Register a rate provider\n */\n registerProvider(provider: RateProvider): void {\n this.providers.set(provider.source, provider)\n }\n\n async fetchRatesForDate(\n date: Date,\n scope: { tenantId: string; organizationId: string },\n options: FetchOptions = {}\n ): Promise<FetchResult> {\n const result: FetchResult = {\n totalFetched: 0,\n byProvider: {},\n errors: [],\n }\n\n // Get existing currencies for validation\n const existingCurrencies = await this.getExistingCurrencies(scope)\n const currencyCodeSet = new Set(existingCurrencies.map((c) => c.code))\n\n // Determine which providers to use\n const providerList = options.providers?.length\n ? options.providers\n : Array.from(this.providers.keys())\n\n // Fetch every provider concurrently: provider calls are independent network I/O,\n // so overlapping them caps total latency at the slowest provider instead of the sum\n // of all provider timeouts. Each provider is isolated in its own try/catch so a\n // single failure never rejects the batch.\n const outcomes = await Promise.all(\n providerList.map((providerSource) =>\n this.fetchFromProvider(providerSource, date, scope, currencyCodeSet)\n )\n )\n\n // Persist sequentially in provider order: a single EntityManager is not safe for\n // concurrent transactions, and stable ordering keeps DB writes and the byProvider\n // response shape deterministic regardless of which fetch resolved first.\n for (const outcome of outcomes) {\n if (outcome.kind === 'skipped') {\n result.errors.push(outcome.error)\n continue\n }\n\n if (outcome.kind === 'failed') {\n result.errors.push(`${outcome.providerSource}: ${outcome.error}`)\n result.byProvider[outcome.providerSource] = { count: 0, errors: [outcome.error] }\n continue\n }\n\n try {\n const stored = await this.storeRates(outcome.rates, scope)\n result.byProvider[outcome.providerSource] = { count: stored }\n result.totalFetched += stored\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n result.errors.push(`${outcome.providerSource}: ${message}`)\n result.byProvider[outcome.providerSource] = { count: 0, errors: [message] }\n }\n }\n\n return result\n }\n\n private async fetchFromProvider(\n providerSource: string,\n date: Date,\n scope: { tenantId: string; organizationId: string },\n currencyCodeSet: Set<string>\n ): Promise<ProviderFetchOutcome> {\n const provider = this.providers.get(providerSource)\n\n if (!provider) {\n return { kind: 'skipped', providerSource, error: `Unknown provider: ${providerSource}` }\n }\n\n if (!provider.isAvailable()) {\n return { kind: 'skipped', providerSource, error: `Provider not available: ${providerSource}` }\n }\n\n try {\n const rates = await provider.fetchRates(date, scope, currencyCodeSet)\n\n // Filter: only currencies that exist in both directions\n const validRates = rates.filter(\n (r) =>\n currencyCodeSet.has(r.fromCurrencyCode) &&\n currencyCodeSet.has(r.toCurrencyCode)\n )\n\n return { kind: 'fetched', providerSource, rates: validRates }\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n return { kind: 'failed', providerSource, error: message }\n }\n }\n\n private async getExistingCurrencies(scope: {\n tenantId: string\n organizationId: string\n }): Promise<Currency[]> {\n return this.em.find(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isActive: true,\n deletedAt: null,\n })\n }\n\n private async storeRates(\n rates: RateProviderResult[],\n scope: { tenantId: string; organizationId: string }\n ): Promise<number> {\n if (rates.length === 0) return 0\n\n let stored = 0\n\n await this.em.transactional(async (em) => {\n // Prefetch every existing rate that could match this batch in a single query,\n // then index by composite key so the per-rate loop never hits the database.\n const fromCurrencyCodes = Array.from(new Set(rates.map((rate) => rate.fromCurrencyCode)))\n const toCurrencyCodes = Array.from(new Set(rates.map((rate) => rate.toCurrencyCode)))\n const sources = Array.from(new Set(rates.map((rate) => rate.source)))\n const dates = Array.from(new Set(rates.map((rate) => rate.date.getTime()))).map(\n (time) => new Date(time)\n )\n\n const existingRates = await em.find(ExchangeRate, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n fromCurrencyCode: { $in: fromCurrencyCodes },\n toCurrencyCode: { $in: toCurrencyCodes },\n date: { $in: dates },\n source: { $in: sources },\n })\n\n const existingByKey = new Map<string, ExchangeRate>()\n for (const existing of existingRates) {\n existingByKey.set(\n exchangeRateKey(existing.fromCurrencyCode, existing.toCurrencyCode, existing.date, existing.source),\n existing\n )\n }\n\n const now = new Date()\n for (const rate of rates) {\n const key = exchangeRateKey(rate.fromCurrencyCode, rate.toCurrencyCode, rate.date, rate.source)\n const existing = existingByKey.get(key)\n\n if (existing) {\n // Update existing rate\n existing.rate = rate.rate\n existing.type = rate.type ?? null\n existing.updatedAt = now\n em.persist(existing)\n } else {\n // Create new rate\n const newRate = em.create(ExchangeRate, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n fromCurrencyCode: rate.fromCurrencyCode,\n toCurrencyCode: rate.toCurrencyCode,\n rate: rate.rate,\n date: rate.date,\n source: rate.source,\n type: rate.type ?? null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(newRate)\n // Track so duplicate keys within the same batch update in memory instead of double-inserting.\n existingByKey.set(key, newRate)\n }\n\n stored++\n }\n\n // Flush all changes at once\n await em.flush()\n })\n\n return stored\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { RateProvider, RateProviderResult } from './providers/base'\nimport { Currency, ExchangeRate } from '../data/entities'\n\nexport interface FetchResult {\n totalFetched: number\n byProvider: Record<string, { count: number; errors?: string[] }>\n errors: string[]\n}\n\nexport interface FetchOptions {\n providers?: string[]\n forceUpdate?: boolean\n}\n\ntype ProviderFetchOutcome =\n | { kind: 'skipped'; providerSource: string; error: string }\n | { kind: 'failed'; providerSource: string; error: string }\n | { kind: 'fetched'; providerSource: string; rates: RateProviderResult[] }\n\nconst exchangeRateKey = (\n fromCurrencyCode: string,\n toCurrencyCode: string,\n date: Date,\n source: string\n): string => `${fromCurrencyCode}|${toCurrencyCode}|${date.getTime()}|${source}`\n\nexport class RateFetchingService {\n private providers: Map<string, RateProvider>\n\n constructor(private em: EntityManager) {\n this.providers = new Map()\n }\n\n /**\n * Register a rate provider\n */\n registerProvider(provider: RateProvider): void {\n this.providers.set(provider.source, provider)\n }\n\n async fetchRatesForDate(\n date: Date,\n scope: { tenantId: string; organizationId: string },\n options: FetchOptions = {}\n ): Promise<FetchResult> {\n const result: FetchResult = {\n totalFetched: 0,\n byProvider: {},\n errors: [],\n }\n\n // Get existing currencies for validation\n const existingCurrencies = await this.getExistingCurrencies(scope)\n const currencyCodeSet = new Set(existingCurrencies.map((c) => c.code))\n\n // Determine which providers to use\n const providerList = options.providers?.length\n ? options.providers\n : Array.from(this.providers.keys())\n\n // Fetch every provider concurrently: provider calls are independent network I/O,\n // so overlapping them caps total latency at the slowest provider instead of the sum\n // of all provider timeouts. Each provider is isolated in its own try/catch so a\n // single failure never rejects the batch.\n const outcomes = await Promise.all(\n providerList.map((providerSource) =>\n this.fetchFromProvider(providerSource, date, scope, currencyCodeSet)\n )\n )\n\n // Persist sequentially in provider order: a single EntityManager is not safe for\n // concurrent transactions, and stable ordering keeps DB writes and the byProvider\n // response shape deterministic regardless of which fetch resolved first.\n for (const outcome of outcomes) {\n if (outcome.kind === 'skipped') {\n result.errors.push(outcome.error)\n continue\n }\n\n if (outcome.kind === 'failed') {\n result.errors.push(`${outcome.providerSource}: ${outcome.error}`)\n result.byProvider[outcome.providerSource] = { count: 0, errors: [outcome.error] }\n continue\n }\n\n try {\n const stored = await this.storeRates(outcome.rates, scope)\n result.byProvider[outcome.providerSource] = { count: stored }\n result.totalFetched += stored\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n result.errors.push(`${outcome.providerSource}: ${message}`)\n result.byProvider[outcome.providerSource] = { count: 0, errors: [message] }\n }\n }\n\n return result\n }\n\n private async fetchFromProvider(\n providerSource: string,\n date: Date,\n scope: { tenantId: string; organizationId: string },\n currencyCodeSet: Set<string>\n ): Promise<ProviderFetchOutcome> {\n const provider = this.providers.get(providerSource)\n\n if (!provider) {\n return { kind: 'skipped', providerSource, error: `Unknown provider: ${providerSource}` }\n }\n\n if (!provider.isAvailable()) {\n return { kind: 'skipped', providerSource, error: `Provider not available: ${providerSource}` }\n }\n\n try {\n const rates = await provider.fetchRates(date, scope, currencyCodeSet)\n\n // Filter: only currencies that exist in both directions\n const validRates = rates.filter(\n (r) =>\n currencyCodeSet.has(r.fromCurrencyCode) &&\n currencyCodeSet.has(r.toCurrencyCode)\n )\n\n return { kind: 'fetched', providerSource, rates: validRates }\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n return { kind: 'failed', providerSource, error: message }\n }\n }\n\n /**\n * Currencies whose rates are worth storing. Deliberately not filtered by `isActive`: that flag\n * answers whether a currency may be picked for something new, which says nothing about whether\n * records already denominated in it must stay convertible. Soft delete is what takes a currency\n * out of rate fetching.\n */\n private async getExistingCurrencies(scope: {\n tenantId: string\n organizationId: string\n }): Promise<Currency[]> {\n return this.em.find(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n }\n\n private async storeRates(\n rates: RateProviderResult[],\n scope: { tenantId: string; organizationId: string }\n ): Promise<number> {\n if (rates.length === 0) return 0\n\n let stored = 0\n\n await this.em.transactional(async (em) => {\n // Prefetch every existing rate that could match this batch in a single query,\n // then index by composite key so the per-rate loop never hits the database.\n const fromCurrencyCodes = Array.from(new Set(rates.map((rate) => rate.fromCurrencyCode)))\n const toCurrencyCodes = Array.from(new Set(rates.map((rate) => rate.toCurrencyCode)))\n const sources = Array.from(new Set(rates.map((rate) => rate.source)))\n const dates = Array.from(new Set(rates.map((rate) => rate.date.getTime()))).map(\n (time) => new Date(time)\n )\n\n const existingRates = await em.find(ExchangeRate, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n fromCurrencyCode: { $in: fromCurrencyCodes },\n toCurrencyCode: { $in: toCurrencyCodes },\n date: { $in: dates },\n source: { $in: sources },\n })\n\n const existingByKey = new Map<string, ExchangeRate>()\n for (const existing of existingRates) {\n existingByKey.set(\n exchangeRateKey(existing.fromCurrencyCode, existing.toCurrencyCode, existing.date, existing.source),\n existing\n )\n }\n\n const now = new Date()\n for (const rate of rates) {\n const key = exchangeRateKey(rate.fromCurrencyCode, rate.toCurrencyCode, rate.date, rate.source)\n const existing = existingByKey.get(key)\n\n if (existing) {\n // Update existing rate\n existing.rate = rate.rate\n existing.type = rate.type ?? null\n existing.updatedAt = now\n em.persist(existing)\n } else {\n // Create new rate\n const newRate = em.create(ExchangeRate, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n fromCurrencyCode: rate.fromCurrencyCode,\n toCurrencyCode: rate.toCurrencyCode,\n rate: rate.rate,\n date: rate.date,\n source: rate.source,\n type: rate.type ?? null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(newRate)\n // Track so duplicate keys within the same batch update in memory instead of double-inserting.\n existingByKey.set(key, newRate)\n }\n\n stored++\n }\n\n // Flush all changes at once\n await em.flush()\n })\n\n return stored\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.7100.1.fbf66fca35",
3
+ "version": "0.6.8-develop.7101.1.bd9356828a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -252,16 +252,16 @@
252
252
  "zod": "^4.4.3"
253
253
  },
254
254
  "peerDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.8-develop.7100.1.fbf66fca35",
256
- "@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
257
- "@open-mercato/ui": "0.6.8-develop.7100.1.fbf66fca35",
255
+ "@open-mercato/ai-assistant": "0.6.8-develop.7101.1.bd9356828a",
256
+ "@open-mercato/shared": "0.6.8-develop.7101.1.bd9356828a",
257
+ "@open-mercato/ui": "0.6.8-develop.7101.1.bd9356828a",
258
258
  "react": "^19.0.0",
259
259
  "react-dom": "^19.0.0"
260
260
  },
261
261
  "devDependencies": {
262
- "@open-mercato/ai-assistant": "0.6.8-develop.7100.1.fbf66fca35",
263
- "@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
264
- "@open-mercato/ui": "0.6.8-develop.7100.1.fbf66fca35",
262
+ "@open-mercato/ai-assistant": "0.6.8-develop.7101.1.bd9356828a",
263
+ "@open-mercato/shared": "0.6.8-develop.7101.1.bd9356828a",
264
+ "@open-mercato/ui": "0.6.8-develop.7101.1.bd9356828a",
265
265
  "@testing-library/dom": "^10.4.1",
266
266
  "@testing-library/jest-dom": "^7.0.0",
267
267
  "@testing-library/react": "^16.3.1",
@@ -9,6 +9,9 @@ Use the currencies module for multi-currency support, exchange rates, and curren
9
9
  3. **MUST record both transaction currency and base currency amounts** — dual recording is mandatory for reporting
10
10
  4. **MUST calculate realized gains/losses** on payment: `(payment rate - invoice rate) × foreign amount`
11
11
  5. **MUST keep financial postings atomic** — full transaction rollback on error
12
+ 6. **MUST NOT filter rate fetching by `isActive`** — that flag decides whether a currency can be
13
+ picked for something new, not whether it still needs a live rate. Rates are fetched for every
14
+ currency in scope that is not soft-deleted. See `services/README.md` → "Which Currencies Get Rates"
12
15
 
13
16
  ## Ask First
14
17
 
@@ -282,6 +282,51 @@ console.log(`By provider:`, result.byProvider)
282
282
  console.log(`Errors:`, result.errors)
283
283
  ```
284
284
 
285
+ ### Which Currencies Get Rates
286
+
287
+ `fetchRatesForDate` first resolves the set of currency codes worth storing rates for, then filters
288
+ every provider response against it — a rate is kept only when **both** legs of its pair are in the
289
+ set. The set is every currency in the tenant/organization scope that is not soft-deleted.
290
+
291
+ `isActive` deliberately plays no part here. That flag answers whether a currency may be **selected**
292
+ for something new (it gates `api/currencies/options`, the `?isActive` list filter and the
293
+ exchange-rate form's currency select), which says nothing about whether records already denominated
294
+ in it must stay convertible. Deactivating a currency while existing records still reference it is
295
+ the normal way to grandfather it, and those records keep needing a rate — otherwise conversions out
296
+ of the currency start failing once the stored rates fall outside the consumer's lookback window.
297
+ `ExchangeRateService` defaults to `maxDaysBack: 30`, but consumers override it — the deal-value
298
+ conversions in `customers/api/deals/{aggregate,summary}` pass `maxDaysBack: 60`, so a currency that
299
+ stopped accruing rates drops out of pipeline totals (`convertedAll: false`, no error surfaced)
300
+ roughly 60 days later.
301
+
302
+ **Soft delete is the only thing that stops a currency from accruing rates.** `deletedAt` excludes it
303
+ from the set; nothing else does, `isActive` included.
304
+
305
+ Reaching `deletedAt` takes two steps, in this order, because `deleteCurrencyCommand` refuses the
306
+ delete while the currency still has live rates — the very rates fetching keeps writing
307
+ (`storeRates` stores each one with `isActive: true`):
308
+
309
+ 1. Delete or deactivate every `exchange_rates` row whose `fromCurrencyCode` or `toCurrencyCode` is
310
+ the currency, otherwise `DELETE /api/currencies/currencies` answers
311
+ `400 Cannot delete currency XXX because it has N active exchange rate(s)`.
312
+ 2. Soft-delete the currency, with no fetch in between. Any fetch in that window — the "Fetch rates"
313
+ button, `mercato currencies fetch-rates`, or `ExchangeRateService`'s `autoFetch` (which defaults
314
+ to `true`) — recreates the rates and re-blocks the delete.
315
+
316
+ There is deliberately **no reversible way to pause rate accrual for a single currency**. An earlier
317
+ draft of this change added a `Currency.fetchRatesWhenInactive` opt-in column for exactly that and it
318
+ was dropped: an extra column to express "grandfathered but still convertible" is the state
319
+ `isActive: false` already describes, and the cost of keeping a currency in the set is bounded (see
320
+ below). Soft delete is the terminal off-switch, not a pause.
321
+
322
+ Note that both bundled providers (NBP, Raiffeisen) return `[]` outright when `PLN` is missing from
323
+ the set, so a `PLN` that fell out of it silenced the entire provider rather than just `PLN`'s own
324
+ pairs — another reason the set is not narrowed by selectability.
325
+
326
+ Keeping the set wide costs no extra provider calls: both providers fetch a full table in a single
327
+ request per date and the set is applied locally. The only cost is additional `exchange_rates` rows,
328
+ bounded by what the provider returns anyway.
329
+
285
330
  ### When to Use RateFetchingService Directly
286
331
 
287
332
  - Scheduled/batch fetching of rates
@@ -131,6 +131,12 @@ export class RateFetchingService {
131
131
  }
132
132
  }
133
133
 
134
+ /**
135
+ * Currencies whose rates are worth storing. Deliberately not filtered by `isActive`: that flag
136
+ * answers whether a currency may be picked for something new, which says nothing about whether
137
+ * records already denominated in it must stay convertible. Soft delete is what takes a currency
138
+ * out of rate fetching.
139
+ */
134
140
  private async getExistingCurrencies(scope: {
135
141
  tenantId: string
136
142
  organizationId: string
@@ -138,7 +144,6 @@ export class RateFetchingService {
138
144
  return this.em.find(Currency, {
139
145
  tenantId: scope.tenantId,
140
146
  organizationId: scope.organizationId,
141
- isActive: true,
142
147
  deletedAt: null,
143
148
  })
144
149
  }