@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/currencies/services/rateFetchingService.js +6 -1
- package/dist/modules/currencies/services/rateFetchingService.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/currencies/AGENTS.md +3 -0
- package/src/modules/currencies/services/README.md +45 -0
- package/src/modules/currencies/services/rateFetchingService.ts +6 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -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
|
|
5
|
-
"mappings": "AAEA,SAAS,UAAU,oBAAoB;AAkBvC,MAAM,kBAAkB,CACtB,kBACA,gBACA,MACA,WACW,GAAG,gBAAgB,IAAI,cAAc,IAAI,KAAK,QAAQ,CAAC,IAAI,MAAM;AAEvE,MAAM,oBAAoB;AAAA,EAG/B,YAAoB,IAAmB;AAAnB;AAClB,SAAK,YAAY,oBAAI,IAAI;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA,EAKA,iBAAiB,UAA8B;AAC7C,SAAK,UAAU,IAAI,SAAS,QAAQ,QAAQ;AAAA,EAC9C;AAAA,EAEA,MAAM,kBACJ,MACA,OACA,UAAwB,CAAC,GACH;AACtB,UAAM,SAAsB;AAAA,MAC1B,cAAc;AAAA,MACd,YAAY,CAAC;AAAA,MACb,QAAQ,CAAC;AAAA,IACX;AAGA,UAAM,qBAAqB,MAAM,KAAK,sBAAsB,KAAK;AACjE,UAAM,kBAAkB,IAAI,IAAI,mBAAmB,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AAGrE,UAAM,eAAe,QAAQ,WAAW,SACpC,QAAQ,YACR,MAAM,KAAK,KAAK,UAAU,KAAK,CAAC;AAMpC,UAAM,WAAW,MAAM,QAAQ;AAAA,MAC7B,aAAa;AAAA,QAAI,CAAC,mBAChB,KAAK,kBAAkB,gBAAgB,MAAM,OAAO,eAAe;AAAA,MACrE;AAAA,IACF;AAKA,eAAW,WAAW,UAAU;AAC9B,UAAI,QAAQ,SAAS,WAAW;AAC9B,eAAO,OAAO,KAAK,QAAQ,KAAK;AAChC;AAAA,MACF;AAEA,UAAI,QAAQ,SAAS,UAAU;AAC7B,eAAO,OAAO,KAAK,GAAG,QAAQ,cAAc,KAAK,QAAQ,KAAK,EAAE;AAChE,eAAO,WAAW,QAAQ,cAAc,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAC,QAAQ,KAAK,EAAE;AAChF;AAAA,MACF;AAEA,UAAI;AACF,cAAM,SAAS,MAAM,KAAK,WAAW,QAAQ,OAAO,KAAK;AACzD,eAAO,WAAW,QAAQ,cAAc,IAAI,EAAE,OAAO,OAAO;AAC5D,eAAO,gBAAgB;AAAA,MACzB,SAAS,KAAK;AACZ,cAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,eAAO,OAAO,KAAK,GAAG,QAAQ,cAAc,KAAK,OAAO,EAAE;AAC1D,eAAO,WAAW,QAAQ,cAAc,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAC,OAAO,EAAE;AAAA,MAC5E;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,kBACZ,gBACA,MACA,OACA,iBAC+B;AAC/B,UAAM,WAAW,KAAK,UAAU,IAAI,cAAc;AAElD,QAAI,CAAC,UAAU;AACb,aAAO,EAAE,MAAM,WAAW,gBAAgB,OAAO,qBAAqB,cAAc,GAAG;AAAA,IACzF;AAEA,QAAI,CAAC,SAAS,YAAY,GAAG;AAC3B,aAAO,EAAE,MAAM,WAAW,gBAAgB,OAAO,2BAA2B,cAAc,GAAG;AAAA,IAC/F;AAEA,QAAI;AACF,YAAM,QAAQ,MAAM,SAAS,WAAW,MAAM,OAAO,eAAe;AAGpE,YAAM,aAAa,MAAM;AAAA,QACvB,CAAC,MACC,gBAAgB,IAAI,EAAE,gBAAgB,KACtC,gBAAgB,IAAI,EAAE,cAAc;AAAA,MACxC;AAEA,aAAO,EAAE,MAAM,WAAW,gBAAgB,OAAO,WAAW;AAAA,IAC9D,SAAS,KAAK;AACZ,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,aAAO,EAAE,MAAM,UAAU,gBAAgB,OAAO,QAAQ;AAAA,IAC1D;AAAA,EACF;AAAA,
|
|
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.
|
|
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.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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
|
}
|