From de7844bb9fbcf00e42887b5625a086b00b30b941 Mon Sep 17 00:00:00 2001 From: Marcin Gajewski Date: Wed, 28 Oct 2026 12:57:09 +0100 Subject: [PATCH 1/2] feat: resolve listing currencies from provider metadata --- CONTRIBUTING.md | 17 ++-- doc/providers.md | 11 ++ lib/FredyPipelineExecutioner.js | 19 ++-- lib/api/routes/dashboardRouter.js | 8 +- lib/api/routes/financeRouter.js | 13 ++- lib/api/routes/jobRouter.js | 4 +- lib/api/routes/listingsRouter.js | 37 +++++-- lib/api/routes/providerRouter.js | 12 +++ lib/mcp/jobDraftContext.js | 9 +- lib/mcp/jobDraftStore.js | 16 ++- lib/mcp/mcpAdapter.js | 15 ++- lib/mcp/mcpNormalizer.js | 4 +- lib/provider/betterhomes.js | 2 + lib/provider/flatfox.js | 1 + lib/services/listings/marketBenchmark.js | 4 +- lib/services/listings/priceHistoryService.js | Bin 6963 -> 7149 bytes lib/services/providers/currency.js | 42 ++++++++ lib/services/providers/providerCurrency.js | 54 ++++++++++ lib/services/storage/listingsStorage.js | 96 +++++++++++++++--- lib/types/listing.js | 1 + lib/utils/formatListing.js | 25 ++--- test/api/dashboardLatest.test.js | 3 + test/api/financeRouter.test.js | 21 +++- test/api/listingsAffordabilityFilter.test.js | 17 ++++ test/api/listingsFilteredDelete.test.js | 16 +++ test/api/mcpCurrency.test.js | 17 ++++ test/api/mcpRentAffordability.test.js | 7 ++ test/provider/providerMetaInformation.test.js | 7 ++ .../services/debug/debugBundleService.test.js | 2 +- test/services/providerCurrency.test.js | 89 ++++++++++++++++ .../listingsStorage.marketBenchmark.test.js | 31 ++++++ test/storage/kpiMedian.test.js | 20 ++++ .../queryListingsAffordability.test.js | 10 +- test/ui/jobFormView.test.js | 14 +++ test/ui/mapPopupEscaping.test.js | 2 +- test/ui/mapTheme.test.js | 2 +- test/ui/priceFormat.test.js | 7 +- test/utils/formatListingCurrency.test.js | 6 +- .../components/grid/listings/ListingsGrid.jsx | 7 +- .../components/listings/PriceChangeBadge.jsx | 6 +- .../components/listings/PricePerSqmBadge.jsx | 4 +- ui/src/components/table/ListingsTable.jsx | 7 +- ui/src/locales/de.json | 5 +- ui/src/locales/en.json | 5 +- ui/src/locales/es.json | 5 +- ui/src/locales/fr.json | 5 +- ui/src/locales/it.json | 5 +- ui/src/locales/tr.json | 5 +- ui/src/services/listings/marketBenchmark.js | 7 +- ui/src/services/price/priceService.js | 17 ++-- ui/src/views/dashboard/Dashboard.jsx | 8 +- .../dashboard/components/LatestListings.jsx | 8 +- ui/src/views/jobs/mutation/JobMutation.jsx | 31 +++++- ui/src/views/listings/Map.jsx | 32 ++++-- .../components/ListingFinanceCard.jsx | 4 + .../listings/components/ListingKeyFacts.jsx | 2 +- .../listings/components/PriceHistoryChart.jsx | 12 ++- ui/src/views/listings/listingFacts.js | 16 +-- ui/src/views/listings/listingPopupContent.jsx | 6 +- 59 files changed, 729 insertions(+), 129 deletions(-) create mode 100644 lib/services/providers/currency.js create mode 100644 lib/services/providers/providerCurrency.js create mode 100644 test/api/mcpCurrency.test.js create mode 100644 test/services/providerCurrency.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b589a2757..e435d1cff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,12 +108,17 @@ The picker orders providers by country and then by size, from behind the ranked ones of its own country. Add it to `PROVIDER_SIZE_ORDER` only if it belongs somewhere specific. A country new to `COUNTRY_ORDER` sorts behind the ones already listed. -The same shape is where anything else that varies by country belongs. `currency` is the obvious -next one and is deliberately not implemented: `€` appears in around forty places, twelve of them in -the finance module, which assumes considerably more than a symbol, and three locale files carry it -inside translated strings. Whoever arrives with a provider quoting in kroner should add -`currency: 'DKK'` here, default it to `EUR`, and resolve it exactly the way `countries` is -resolved in `lib/services/providers/`. +A provider that quotes prices outside the euro declares `currency` on `metaInformation`, for +example `currency: 'DKK'`. The default is `EUR`. Resolution lives in +`lib/services/providers/`: `currency.js` is the pure half, while `providerCurrency.js` reads the +loaded provider metadata. A provider serving several currencies can declare +`currencyOf(listing)`, like `countryOf`, to resolve the currency from the listing's link. + +Amounts stay in the portal's currency; Fredy does not convert them. Listing views, notifications +and MCP carry that currency. Monetary dashboard aggregates are hidden for mixed currencies, and +a numeric price range or job price ceiling needs sources that share one currency. The finance +module still models EUR and German purchase costs, so non-EUR listings receive no finance result +or affordability verdict. Do not add a country-specific currency map elsewhere in the app. ### How to write new notification adapter? diff --git a/doc/providers.md b/doc/providers.md index 5039cf053..e38cc21b7 100644 --- a/doc/providers.md +++ b/doc/providers.md @@ -208,6 +208,17 @@ export const metaInformation = { Addresses are then geocoded in those countries, and the map opens on them so you can draw a search area there. A provider spanning several is fine: `countries: ['de', 'at', 'ch']`. +## Currencies + +Providers declare `metaInformation.currency`, with `EUR` as the default. Flatfox uses `CHF`; +BETTERHOMES resolves EUR or CHF from the advert's national domain. Prices stay in their original +currency throughout listing views, notifications, price history and MCP. Fredy does not convert +between currencies. + +A dashboard covering several currencies hides its monetary medians. Price ranges need a selection +with one currency, and a job price ceiling cannot span currencies. The finance calculator retains +its EUR and German purchase-cost assumptions: it does not score non-EUR listings. + ## 🛡️ Bot detection & proxies Most browser-based providers (kleinanzeigen, wg-gesucht, ohnemakler, ...) are scraped through a diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index 89d6f5f5d..aed96e6fd 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -29,7 +29,8 @@ import { distancesToAddresses } from './services/listings/distanceCalculator.js' import { updateTravelTimesForListings } from './services/listings/travelTimeSweeper.js'; import { getSettings, getUserSettings, getAddresses } from './services/storage/settingsStorage.js'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; -import { currencyForCountries, formatListing } from './utils/formatListing.js'; +import { formatListing } from './utils/formatListing.js'; +import { getCurrencyContext } from './services/providers/providerCurrency.js'; import { addressesWithBudget, exceedsCommuteBudget, normalizeCommuteFilter } from './utils/commuteBudget.js'; import { sleep } from './utils.js'; import { enqueuePreviewCapture } from './services/listings/previewCaptureQueue.js'; @@ -584,10 +585,9 @@ class FredyPipelineExecutioner { // longer gets "3 Zimmer" in every notification. const userId = getJob(this._jobKey)?.userId; const language = getUserSettings(userId)?.language ?? 'en'; - const formattedListings = await Promise.all( - newListings.map(async (listing) => - formatListing(listing, language, currencyForCountries(await getCountriesForListing(this._providerId, listing))), - ), + const { resolve } = await getCurrencyContext(); + const formattedListings = newListings.map((listing) => + formatListing(listing, language, resolve({ ...listing, provider: this._providerId })), ); const settings = await getSettings(); const baseUrl = settings?.baseUrl ?? ''; @@ -639,10 +639,15 @@ class FredyPipelineExecutioner { * @returns {ParsedListing[]} The same listings, with the figures attached where one was found. * @private */ - _benchmarkMarket(listings) { + async _benchmarkMarket(listings) { if (listings.length === 0) return listings; try { - applyMarketBenchmark(this._jobKey, listings); + // The provider is written here rather than read back per row inside the benchmark: the run + // already knows it, and a listing carrying it is what keeps the fallback query away. + for (const listing of listings) { + if (listing.provider == null) listing.provider = this._providerId; + } + applyMarketBenchmark(this._jobKey, listings, await getCurrencyContext()); } catch (err) { logger.warn('Could not compute the price per square metre benchmark for the new listings', err); } diff --git a/lib/api/routes/dashboardRouter.js b/lib/api/routes/dashboardRouter.js index 05084c6e0..9d7a24959 100644 --- a/lib/api/routes/dashboardRouter.js +++ b/lib/api/routes/dashboardRouter.js @@ -12,6 +12,7 @@ import { getProviderDistributionForJobIds, } from '../../services/storage/listingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js'; +import { getCurrencyContext } from '../../services/providers/providerCurrency.js'; import { canAccessJob } from '../../services/security/access.js'; /** Two weeks: long enough to show a shape, short enough to stay readable as a sparkline. */ @@ -94,13 +95,15 @@ export default async function dashboardPlugin(fastify) { const totalJobs = jobs.length; const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0); const jobIds = jobs.map((j) => j.id); + const currencyContext = await getCurrencyContext(); const { numberOfListings, numberOfActiveListings, medianPriceOfListings, + currency, medianPriceSampleSize, medianPricePerSqm, - } = getListingsKpisForJobIds(jobIds); + } = getListingsKpisForJobIds(jobIds, currencyContext); const providerPieRaw = getProviderDistributionForJobIds(jobIds); const providerPie = Array.isArray(providerPieRaw) @@ -131,6 +134,7 @@ export default async function dashboardPlugin(fastify) { numberOfListings, numberOfActiveListings, medianPriceOfListings, + currency, medianPriceSampleSize, medianPricePerSqm, }, @@ -139,7 +143,7 @@ export default async function dashboardPlugin(fastify) { ...compareWeeks(perDay), }, pie: providerPie, - latest: getLatestListingsForJobIds(jobIds, 4), + latest: getLatestListingsForJobIds(jobIds, 4, currencyContext), jobActivity: getListingActivityPerJob(jobIds, 7), }; }); diff --git a/lib/api/routes/financeRouter.js b/lib/api/routes/financeRouter.js index 07de15a71..685cdc64a 100644 --- a/lib/api/routes/financeRouter.js +++ b/lib/api/routes/financeRouter.js @@ -3,6 +3,7 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ +import { getCurrencyContext } from '../../services/providers/providerCurrency.js'; import * as listingStorage from '../../services/storage/listingsStorage.js'; import { isAdmin as isAdminFn } from '../security.js'; import logger from '../../services/logger.js'; @@ -106,6 +107,7 @@ export default async function financePlugin(fastify) { fastify.get('/listing/:listingId', async (request, reply) => { const { listingId } = request.params; const userId = request.session.currentUser; + const currencyContext = await getCurrencyContext(); const listing = listingStorage.getListingById(listingId, userId, isAdminFn(request)); if (listing == null) { return reply.code(404).send({ error: 'Listing not found' }); @@ -114,6 +116,9 @@ export default async function financePlugin(fastify) { const stored = getUserSettings(userId)?.finance_profile ?? null; const summary = summarizeProfile(stored); const price = Number(listing.price); + if (currencyContext.resolve(listing) !== 'EUR') { + return { dealType: listing.dealType ?? null, scored: null, result: null, ...summary, unsupportedCurrency: true }; + } if (!Number.isFinite(price) || price <= 0) { return { dealType: listing.dealType ?? null, scored: null, result: null, ...summary }; } @@ -179,7 +184,9 @@ export default async function financePlugin(fastify) { try { // Reuse the normal listings query so this endpoint inherits the exact same user // scoping - a user can never score listings belonging to someone else. + const currencyContext = await getCurrencyContext(); const { result: listings } = listingStorage.queryListings({ + currencyContext, page: 1, pageSize: MAX_SCORED_LISTINGS, jobIdFilter: nullIfEmpty(filter.jobId), @@ -191,10 +198,14 @@ export default async function financePlugin(fastify) { isAdmin: isAdminFn(request), }); - const skipped = { noPrice: 0, incompleteProfile: 0 }; + const skipped = { noPrice: 0, incompleteProfile: 0, unsupportedCurrency: 0 }; const items = []; for (const listing of listings) { + if (currencyContext.resolve(listing) !== 'EUR') { + skipped.unsupportedCurrency++; + continue; + } const price = Number(listing.price); if (!Number.isFinite(price) || price <= 0) { skipped.noPrice++; diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index eb33ced63..49cfbd83b 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -18,6 +18,7 @@ import { canUseChannel } from '../../services/security/channelAccess.js'; import * as channelStorage from '../../services/storage/configuredAdapterStorage.js'; import { canAccessJob, canModifyJob } from '../../services/security/access.js'; import { normalizeCommuteFilter } from '../../utils/commuteBudget.js'; +import { jobCurrencyProblem } from '../../services/providers/currency.js'; import { providerListProblem } from '../../services/jobs/providerUrl.js'; import { getProviders } from '../../utils.js'; @@ -244,7 +245,8 @@ export default async function jobPlugin(fastify) { const providerMeta = new Map( (await getProviders()).map((module) => [module?.metaInformation?.id, module?.metaInformation]), ); - const providerProblem = providerListProblem(provider, providerMeta); + const providerProblem = + providerListProblem(provider, providerMeta) ?? jobCurrencyProblem(provider, providerMeta, specFilter); if (providerProblem != null) { return reply.code(400).send({ error: providerProblem }); } diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 868907d4b..5a1c9e24c 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -18,6 +18,7 @@ import { canAccessJob } from '../../services/security/access.js'; import { updateDistancesForListing } from '../../services/geocoding/distanceService.js'; import { geocodeAddress, isGeocodingPaused } from '../../services/geocoding/geoCodingService.js'; import { getCountriesForListing } from '../../services/providers/providerCountries.js'; +import { getCurrencyContext } from '../../services/providers/providerCurrency.js'; import { SCAM_OVERRIDES } from '../../services/listings/scamSignals.js'; import { buildApplicationLetter } from '../../services/application/applicationService.js'; import { refetchListingDetailsOnce } from '../../services/listings/detailRefetchService.js'; @@ -47,7 +48,9 @@ const NO_ACCESS_MESSAGE = 'You are trying to access a listing that is not associ * @returns {'affordable'|'stretch'|'unaffordable'|null} */ const verdictForRow = (listing, thresholds) => - verdictForListing(listing.price, listing.dealType, thresholds, { chargesIncluded: rentIncludesCharges(listing) }); + (listing.currency ?? 'EUR') === 'EUR' + ? verdictForListing(listing.price, listing.dealType, thresholds, { chargesIncluded: rentIncludesCharges(listing) }) + : null; /** * A single listing with its stored gallery. Only the single read carries it: at most twenty urls, @@ -99,6 +102,7 @@ export default async function listingsPlugin(fastify) { const pageData = listingStorage.queryListings({ ...filters, + currencyContext: await getCurrencyContext(), page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 50, sortField: sortfield || null, @@ -128,13 +132,16 @@ export default async function listingsPlugin(fastify) { jobId: nullOrEmpty(jobId) ? null : jobId, userId: request.session.currentUser, isAdmin: isAdminFn(request), + currencyContext: await getCurrencyContext(), }); }); fastify.get('/:listingId', async (request, reply) => { const { listingId } = request.params; const userId = request.session.currentUser; - const listing = listingStorage.getListingById(listingId, userId, isAdminFn(request)); + const listing = listingStorage.getListingById(listingId, userId, isAdminFn(request), { + currencyContext: await getCurrencyContext(), + }); if (!listing) { return reply.code(404).send({ message: 'Listing not found' }); } @@ -338,7 +345,11 @@ export default async function listingsPlugin(fastify) { await trackPoi(TRACKING_POIS.LISTING_ADDRESS_MANUAL); // The refreshed listing goes back with the response so the detail view can redraw without a // second round trip. - return reply.send(listingStorage.getListingById(listingId, userId, isAdminFn(request))); + return reply.send( + listingStorage.getListingById(listingId, userId, isAdminFn(request), { + currencyContext: await getCurrencyContext(), + }), + ); } catch (error) { logger.error(error); return reply.code(500).send({ message: 'Failed to update listing address' }); @@ -410,7 +421,9 @@ export default async function listingsPlugin(fastify) { // a second round trip, the same way the manual address endpoint above does. return reply.send({ status: 'found', - listing: listingStorage.getListingById(listingId, userId, isAdminFn(request)), + listing: listingStorage.getListingById(listingId, userId, isAdminFn(request), { + currencyContext: await getCurrencyContext(), + }), }); } catch (error) { logger.error(error); @@ -465,7 +478,13 @@ export default async function listingsPlugin(fastify) { return reply.send({ status, ...(status === 'updated' - ? { listing: withGallery(listingStorage.getListingById(listingId, userId, isAdminFn(request))) } + ? { + listing: withGallery( + listingStorage.getListingById(listingId, userId, isAdminFn(request), { + currencyContext: await getCurrencyContext(), + }), + ), + } : {}), }); } catch (error) { @@ -649,11 +668,15 @@ export default async function listingsPlugin(fastify) { userId: request.session.currentUser, isAdmin: isAdminFn(request), }); - const { changes } = listingStorage.deleteListingsByFilter(filters, hardDelete === true); + const { changes } = listingStorage.deleteListingsByFilter( + { ...filters, currencyContext: await getCurrencyContext() }, + hardDelete === true, + ); return reply.send({ deleted: changes }); } catch (error) { logger.error(error); - return reply.code(500).send({ error: error.message }); + // The storage layer's currency guard throws a 400; it must not surface as a 500. + return reply.code(error.statusCode ?? 500).send({ error: error.message }); } }); diff --git a/lib/api/routes/providerRouter.js b/lib/api/routes/providerRouter.js index 5c93e5acd..6fa38a427 100644 --- a/lib/api/routes/providerRouter.js +++ b/lib/api/routes/providerRouter.js @@ -4,6 +4,7 @@ */ import { getProviders } from '../../utils.js'; +import { currencyForListing } from '../../services/providers/currency.js'; // Same cached loader the job execution service uses, so there is one list of providers rather than // two - and it resolves against the module's own location instead of the working directory. @@ -19,6 +20,17 @@ export default async function providerPlugin(fastify) { // sent, and the flag can never claim a reader that is not there. return providers.map((p) => ({ ...p.metaInformation, + currency: currencyForListing(p.metaInformation), + ...(typeof p.metaInformation.currencyOf !== 'function' + ? {} + : { + currencyByHost: Object.fromEntries( + (p.metaInformation.hosts ?? []).map((host) => [ + host, + currencyForListing(p.metaInformation, { link: `https://${host}/` }), + ]), + ), + }), addByUrl: typeof p.config?.fetchListingByUrl === 'function', })); }); diff --git a/lib/mcp/jobDraftContext.js b/lib/mcp/jobDraftContext.js index b1f69b9ef..eeea2e79a 100644 --- a/lib/mcp/jobDraftContext.js +++ b/lib/mcp/jobDraftContext.js @@ -29,7 +29,14 @@ export async function buildDraftContext(user) { const providers = (await getProviders()) .map((provider) => provider.metaInformation) .filter((meta) => meta?.id != null && meta?.baseUrl != null) - .map(({ id, name, baseUrl, hosts }) => ({ id, name, baseUrl, ...(hosts == null ? {} : { hosts }) })); + .map(({ id, name, baseUrl, hosts, currencyOf, currency }) => ({ + id, + name, + baseUrl, + currencyOf, + currency, + ...(hosts == null ? {} : { hosts }), + })); // `fields` is dropped here rather than at the point of rendering: a channel object that still // carries its token cannot leak from a place it never reaches. diff --git a/lib/mcp/jobDraftStore.js b/lib/mcp/jobDraftStore.js index 772e99151..6588b21ee 100644 --- a/lib/mcp/jobDraftStore.js +++ b/lib/mcp/jobDraftStore.js @@ -27,6 +27,7 @@ import { hostsOf, normalizeHost, resolveProviderForUrl, validateProviderUrl } from '../services/jobs/providerUrl.js'; import { DEAL_TYPES, normalizeDealType, detectDealTypeForJob } from '../services/dealType.js'; +import { currencyForListing, jobCurrencyProblem } from '../services/providers/currency.js'; import { normalizeCommuteFilter } from '../utils/commuteBudget.js'; /** @@ -340,6 +341,12 @@ export function applyDraftUpdate(draft, patch, ctx) { next.enabled = patch.enabled === true; } + const currencyProblem = jobCurrencyProblem( + next.providers, + new Map(ctx.providers.map((p) => [p.id, p])), + next.specFilter, + ); + if (currencyProblem) problems.push(currencyProblem); if (problems.length > 0) return { ok: false, draft, problems }; if (touchedRefinement) next.refinementsAnswered = true; @@ -402,7 +409,12 @@ export function describeDraft(draft, ctx) { spec == null ? '–' : [ - spec.maxPrice != null ? `max ${spec.maxPrice} EUR` : null, + spec.maxPrice != null + ? `max ${spec.maxPrice} ${currencyForListing( + ctx.providers.find((p) => p.id === draft.providers[0]?.id), + { link: draft.providers[0]?.url }, + )}` + : null, spec.minSize != null ? `min ${spec.minSize} m²` : null, spec.minRooms != null ? `min ${spec.minRooms} rooms` : null, ] @@ -478,7 +490,7 @@ export function nextStep(draft, ctx) { return { key: 'refinements', question: - 'Optional refinements - answer with any of these, or say "skip": maximum price in EUR, minimum size in m², ' + + 'Optional refinements - answer with any of these, or say "skip": maximum price in the selected portals’ currency, minimum size in m², ' + 'minimum number of rooms, words that should exclude a listing, ' + (addresses.length > 0 ? `travel-time limits in minutes for your saved addresses (${addresses.join(', ')}), ` diff --git a/lib/mcp/mcpAdapter.js b/lib/mcp/mcpAdapter.js index d21cf17bd..9e84196be 100644 --- a/lib/mcp/mcpAdapter.js +++ b/lib/mcp/mcpAdapter.js @@ -3,6 +3,7 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ +import { getCurrencyContext, listingCurrencyResolver } from '../services/providers/providerCurrency.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { nanoid } from 'nanoid'; @@ -267,7 +268,9 @@ export function createMcpServer() { const safePageSize = pageSize ?? 50; const mobileMask = mobileTech ? filterMask(mobileTech, mobileOperator ?? null) : null; + const currencyContext = await getCurrencyContext(); const result = queryListings({ + currencyContext, page: safePage, pageSize: safePageSize, freeTextFilter: filter, @@ -309,6 +312,7 @@ export function createMcpServer() { return normalizeError('Listing not found or access denied.', 'get_listing'); } + listing.currency = (await listingCurrencyResolver())(listing); return normalizeGetListing(listing); }, ); @@ -454,6 +458,10 @@ export function createMcpServer() { if (!listing) { return normalizeError('Listing not found.', 'calculate_financing'); } + listing.currency = (await listingCurrencyResolver())(listing); + if (listing.currency !== 'EUR') { + return normalizeError('Financing supports EUR listings only.', 'calculate_financing'); + } if (listing.price == null) { return normalizeError('That listing has no price, so it cannot be financed.', 'calculate_financing'); } @@ -750,7 +758,12 @@ export function createMcpServer() { .describe('Words that exclude a listing when they appear in it. The full list; an empty list clears it.'), specFilter: z .object({ - maxPrice: z.number().positive().nullable().optional().describe('Maximum price in EUR'), + maxPrice: z + .number() + .positive() + .nullable() + .optional() + .describe('Maximum price in the selected providers’ currency; providers must share one currency'), minSize: z.number().positive().nullable().optional().describe('Minimum size in m²'), minRooms: z.number().positive().nullable().optional().describe('Minimum number of rooms'), }) diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js index 40e5c627b..60451e349 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -192,7 +192,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) { for (const l of listings) { // Listed is the date the portal itself states, which is the order the web UI reads in; Found // is when Fredy first stored the row. They differ whenever a flat is discovered late. - md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${dealTypeLabel(l.dealType)} | ${cell(l.size)} | ${cell(l.provider)} | ${cell(internetCell(l))} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${l.published_at != null ? formatDate(l.published_at) : '–'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; + md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)}${l.currency ? ` ${cell(l.currency)}` : ''} | ${dealTypeLabel(l.dealType)} | ${cell(l.size)} | ${cell(l.provider)} | ${cell(internetCell(l))} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${l.published_at != null ? formatDate(l.published_at) : '–'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; } md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`; } else { @@ -215,7 +215,7 @@ export function normalizeGetListing(listing) { md += `- **Title:** ${listing.title || '–'}\n`; md += `- **Description:** ${listing.description || '–'}\n`; md += `- **Address:** ${listing.address || '–'}\n`; - md += `- **Price:** ${listing.price ?? '–'}${listing.dealType === 'rent' ? ' (monthly rent)' : ''}\n`; + md += `- **Price:** ${listing.price ?? '–'}${listing.currency ? ` ${listing.currency}` : ''}${listing.dealType === 'rent' ? ' (monthly rent)' : ''}\n`; md += `- **Deal type:** ${dealTypeLabel(listing.dealType)}\n`; md += `- **Size:** ${listing.size ?? '–'}\n`; md += `- **Year built:** ${listing.build_year ?? '–'}\n`; diff --git a/lib/provider/betterhomes.js b/lib/provider/betterhomes.js index 1755d10d3..7c8f9c7a7 100644 --- a/lib/provider/betterhomes.js +++ b/lib/provider/betterhomes.js @@ -675,6 +675,8 @@ export const createConfig = (sourceConfig, blacklist = []) => ({ export const metaInformation = { countries: ['de', 'at', 'ch'], + currency: 'EUR', + currencyOf: (listing) => (countryOf(listing) === 'ch' ? 'CHF' : 'EUR'), countryOf, // One application on three domains, so a job url may name any of them. Checking a pasted url // against `baseUrl` alone would refuse every Austrian and Swiss search the provider can run. diff --git a/lib/provider/flatfox.js b/lib/provider/flatfox.js index cb84918d7..ce71af922 100644 --- a/lib/provider/flatfox.js +++ b/lib/provider/flatfox.js @@ -205,6 +205,7 @@ const config = { export const metaInformation = { countries: ['ch'], + currency: 'CHF', name: 'Flatfox', baseUrl: BASE_URL, id: 'flatfox', diff --git a/lib/services/listings/marketBenchmark.js b/lib/services/listings/marketBenchmark.js index e56c163a5..48be0a704 100644 --- a/lib/services/listings/marketBenchmark.js +++ b/lib/services/listings/marketBenchmark.js @@ -111,7 +111,9 @@ const KM_PER_DEGREE_LAT = 111.32; * * - `l.id != @id` keeps a listing out of the sample it is measured against. With eight neighbours * its own price would pull the median by an eighth of the way towards itself, which is exactly - * the direction that hides an outlier. + * the direction that hides an outlier. The literal `WHERE l.id != @id` below is load-bearing + * beyond that: `applyMarketBenchmark` in listingsStorage.js rewrites it to keep currencies + * apart, so rewording it silently mixes francs and euros into one median again. * - `j.deal_type = @dealType` is not optional. A purchase and a rental share the `price` column at * two completely different magnitudes, and one purchase in the sample moves a rental median by * orders of magnitude. diff --git a/lib/services/listings/priceHistoryService.js b/lib/services/listings/priceHistoryService.js index 610d006beb964863df7d21c5f0aaa10ddf64b131..59bff7868f301cee25413827cc8292f2c21b6562 100644 GIT binary patch delta 195 zcmdmN_SSsEaz?9ah4j=C=hC90)V$?T7|Tt{9FZfJw5$`qWrSVl+>bP zI18qD^DM@*A}lc#hH0BK#9Em(suhY-i}Q2JQi0lS6%xx6GfS|Tt)V&jzN8rkmjVP# u7L`&q)`95+nxUtsmz0l*Xi0JE|dg#(jq8Yq*U8Y2NIlk*z-?BESk diff --git a/lib/services/providers/currency.js b/lib/services/providers/currency.js new file mode 100644 index 000000000..80cf9271a --- /dev/null +++ b/lib/services/providers/currency.js @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** The fallback for a provider without a currency declaration. */ +export const DEFAULT_CURRENCY = 'EUR'; + +/** + * Resolve a provider's declaration, optionally narrowed for one listing. + * @param {{currency?: string, currencyOf?: (listing: Object) => string}|null} meta + * @param {Object} [listing] + * @returns {string} ISO 4217 code; EUR when absent or malformed. + */ +export function currencyForListing(meta, listing) { + const declared = + typeof meta?.currency === 'string' && /^[A-Z]{3}$/.test(meta.currency) ? meta.currency : DEFAULT_CURRENCY; + if (listing != null && typeof meta?.currencyOf === 'function') { + try { + const currency = meta.currencyOf(listing); + if (typeof currency === 'string' && /^[A-Z]{3}$/.test(currency)) return currency; + } catch { + // A provider must not prevent the rest of a batch from being read. + } + } + return declared; +} + +/** + * A job price ceiling has one unit, so all enabled sources must share it. + * @param {Object[]} providers Job sources. + * @param {Map} metadata Metadata by provider id. + * @param {Object|null} specFilter Job constraints. + * @returns {string|null} + */ +export function jobCurrencyProblem(providers, metadata, specFilter) { + if (specFilter?.maxPrice == null || !Array.isArray(providers)) return null; + const currencies = new Set( + providers.filter((p) => p.enabled !== false).map((p) => currencyForListing(metadata.get(p.id), { link: p.url })), + ); + return currencies.size > 1 ? 'A maximum price requires providers with the same currency.' : null; +} diff --git a/lib/services/providers/providerCurrency.js b/lib/services/providers/providerCurrency.js new file mode 100644 index 000000000..c1a4d0a2d --- /dev/null +++ b/lib/services/providers/providerCurrency.js @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { getProviders } from '../../utils.js'; +import { currencyForListing } from './currency.js'; + +/** + * @typedef {{resolve: (listing: Object) => string, euroSql: string}} CurrencyContext + */ + +/** + * Resolve metadata once for a request or job run. No provider-specific map or mutable registry. + * @param {Object[]} providers Loaded provider modules. + * @returns {CurrencyContext} + */ +export function currencyContextForProviders(providers) { + const metadata = new Map(providers.map((p) => [p.metaInformation.id, p.metaInformation])); + const quote = (value) => `'${value.replaceAll("'", "''")}'`; + const conditions = [...metadata.values()].flatMap((meta) => { + if (typeof meta.currencyOf === 'function') { + const hosts = meta.hosts ?? (meta.baseUrl ? [new URL(meta.baseUrl).hostname.replace(/^www\./, '')] : []); + const euroLinks = hosts + .filter((host) => currencyForListing(meta, { link: `https://${host}/` }) === 'EUR') + .flatMap((host) => [ + `l.link LIKE ${quote(`https://${host}/%`)}`, + `l.link LIKE ${quote(`https://www.${host}/%`)}`, + ]); + return [`(l.provider != ${quote(meta.id)}${euroLinks.length ? ` OR ${euroLinks.join(' OR ')}` : ''})`]; + } + return currencyForListing(meta) === 'EUR' ? [] : [`l.provider != ${quote(meta.id)}`]; + }); + return { + resolve: (listing) => currencyForListing(metadata.get(listing?.provider), listing), + euroSql: conditions.length ? conditions.join(' AND ') : '1 = 1', + }; +} + +/** @returns {Promise>} */ +export async function getCurrencyContext() { + return currencyContextForProviders(await getProviders()); +} + +/** @returns {Promise<(listing: Object) => string>} */ +export async function listingCurrencyResolver() { + return (await getCurrencyContext()).resolve; +} + +/** @param {string} providerId @param {Object|null} listing @returns {Promise} */ +export async function getCurrencyForListing(providerId, listing) { + const meta = (await getProviders()).find((p) => p.metaInformation.id === providerId)?.metaInformation; + return currencyForListing(meta, listing); +} diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index 9af05b543..274344a24 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -11,6 +11,9 @@ import { DEAL_TYPES } from '../dealType.js'; import { computeBenchmark, pricePerSqm } from '../listings/marketBenchmark.js'; import { SCAM_OVERRIDES, detectScamSignals } from '../listings/scamSignals.js'; import { storeListingGallery } from './listingMediaStorage.js'; +import { DEFAULT_CURRENCY } from '../providers/currency.js'; + +const DEFAULT_CURRENCY_CONTEXT = { resolve: () => DEFAULT_CURRENCY, euroSql: '1 = 1' }; /** * How many ids go into a single `IN (...)` list. @@ -219,11 +222,12 @@ function toDayKey(date) { * served straight from an index. * * @param {string[]} jobIds + * @param {import('../providers/providerCurrency.js').CurrencyContext} [currencyContext] Resolved provider metadata. * @returns {{ numberOfListings: number, numberOfActiveListings: number, medianPriceOfListings: number, * medianPriceSampleSize: number, medianPricePerSqm: ({dealType: ('rent'|'buy'), value: number, * sampleSize: number}|null) }} */ -export const getListingsKpisForJobIds = (jobIds = []) => { +export const getListingsKpisForJobIds = (jobIds = [], currencyContext = DEFAULT_CURRENCY_CONTEXT) => { if (!Array.isArray(jobIds) || jobIds.length === 0) { return { numberOfListings: 0, @@ -248,6 +252,15 @@ export const getListingsKpisForJobIds = (jobIds = []) => { const totalCount = Number(counts?.totalCount ?? 0); const activeCount = Number(counts?.activeCount ?? 0); const pricedCount = Number(counts?.pricedCount ?? 0); + const currencies = [ + ...new Set( + SqliteConnection.query( + `SELECT DISTINCT provider, substr(link, 1, instr(substr(link, 9), '/') + 8) AS link ${from}`, + jobIds, + ).map(currencyContext.resolve), + ), + ]; + const currency = currencies.length <= 1 ? (currencies[0] ?? 'EUR') : null; let medianPrice = 0; if (pricedCount > 0) { @@ -270,11 +283,12 @@ export const getListingsKpisForJobIds = (jobIds = []) => { // found" names. `numberOfFoundListings` on a job counts only the active ones. numberOfListings: totalCount, numberOfActiveListings: activeCount, - medianPriceOfListings: medianPrice, + medianPriceOfListings: currency == null ? null : medianPrice, + currency, // The population the median price is taken over, which is neither the active count nor the // price-per-m² sample of one deal type. - medianPriceSampleSize: pricedCount, - medianPricePerSqm: medianPricePerSqmForJobIds(jobIds), + medianPriceSampleSize: currency == null ? 0 : pricedCount, + medianPricePerSqm: currency == null ? null : medianPricePerSqmForJobIds(jobIds), }; }; @@ -388,9 +402,10 @@ export const getProviderDistributionForJobIds = (jobIds = []) => { * * @param {string[]} jobIds * @param {number} [limit=4] + * @param {import('../providers/providerCurrency.js').CurrencyContext} [currencyContext] Resolved provider metadata. * @returns {Array} */ -export const getLatestListingsForJobIds = (jobIds = [], limit = 4) => { +export const getLatestListingsForJobIds = (jobIds = [], limit = 4, currencyContext = DEFAULT_CURRENCY_CONTEXT) => { if (!Array.isArray(jobIds) || jobIds.length === 0) { return []; } @@ -399,7 +414,7 @@ export const getLatestListingsForJobIds = (jobIds = [], limit = 4) => { const count = Number.isInteger(limit) && limit > 0 && limit <= 50 ? limit : 4; const placeholders = jobIds.map(() => '?').join(','); return SqliteConnection.query( - `SELECT id, title, address, price, size, rooms, price_per_sqm, image_url, created_at, job_id, provider, + `SELECT id, title, address, price, size, rooms, price_per_sqm, image_url, created_at, job_id, provider, link, market_median_sqm, market_sample_size, market_radius_km FROM listings WHERE job_id IN (${placeholders}) @@ -408,7 +423,7 @@ export const getLatestListingsForJobIds = (jobIds = [], limit = 4) => { ORDER BY created_at DESC LIMIT ${count}`, jobIds, - ); + ).map((row) => ({ ...parseListingStatus(row), currency: currencyContext.resolve(row) })); }; /** @@ -1121,9 +1136,10 @@ export const storeListings = (jobId, providerId, listings) => { * * @param {string} jobId Job the listings belong to. * @param {Array<{id: string, latitude?: number|null, longitude?: number|null}>} listings + * @param {import('../providers/providerCurrency.js').CurrencyContext} [currencyContext] Resolved provider metadata. * @returns {void} */ -export const applyMarketBenchmark = (jobId, listings) => { +export const applyMarketBenchmark = (jobId, listings, currencyContext = DEFAULT_CURRENCY_CONTEXT) => { if (!jobId || !Array.isArray(listings) || listings.length === 0) { return; } @@ -1133,8 +1149,20 @@ export const applyMarketBenchmark = (jobId, listings) => { return; } - const runQuery = (sql, params) => SqliteConnection.query(sql, params); for (const listing of listings) { + const provider = + listing.provider ?? + SqliteConnection.query('SELECT provider FROM listings WHERE id = ?', [listing.id])[0]?.provider; + // shortcut: non-EUR benchmarks use one portal, widen only with currency-aware SQL metadata. + const sameProvider = currencyContext.resolve({ ...listing, provider }) !== 'EUR'; + const runQuery = (sql, params) => + SqliteConnection.query( + sql.replace( + 'WHERE l.id != @id', + `WHERE l.id != @id AND ${sameProvider ? `l.provider = @benchmarkProvider AND NOT (${currencyContext.euroSql})` : currencyContext.euroSql}`, + ), + sameProvider ? { ...params, benchmarkProvider: provider } : params, + ); const benchmark = computeBenchmark(listing, dealType, runQuery); if (benchmark == null) { continue; @@ -1230,6 +1258,7 @@ export const setListingScamOverride = (id, override) => { * filters) and its named bind parameters. */ function buildListingWhere({ + currencyContext = DEFAULT_CURRENCY_CONTEXT, activityFilter, jobNameFilter, jobIdFilter, @@ -1313,6 +1342,21 @@ function buildListingWhere({ params.createdBefore = createdBefore; whereParts.push('(l.created_at <= @createdBefore)'); } + // Check the unpriced scope so a bound cannot silently hide another currency. + const priceScope = [...whereParts, hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)']; + if (Number.isFinite(minPrice) || Number.isFinite(maxPrice)) { + const providers = SqliteConnection.query( + `SELECT DISTINCT l.provider, substr(l.link, 1, instr(substr(l.link, 9), '/') + 8) AS link + FROM listings l JOIN jobs j ON j.id = l.job_id + LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId WHERE ${priceScope.join(' AND ')}`, + params, + ); + if (new Set(providers.map(currencyContext.resolve)).size > 1) { + const error = new Error('Select one currency before filtering by price.'); + error.statusCode = 400; + throw error; + } + } // Price range filters if (Number.isFinite(minPrice) && minPrice >= 0) { params.minPrice = minPrice; @@ -1336,6 +1380,7 @@ function buildListingWhere({ // ones - the same choice `verdictForRent` makes for the chip. `IS NOT 1` rather than `!= 1`, so a // row whose portal never said (NULL) stays on the cold bounds instead of falling out of both arms. if (affordabilityBand != null) { + whereParts.push(currencyContext.euroSql); const arms = []; for (const dealType of [DEAL_TYPES.BUY, DEAL_TYPES.RENT]) { const window = affordabilityBand[dealType]; @@ -1495,9 +1540,11 @@ const LISTING_FILTER_FROM_SQL = `FROM listings l * @param {number|null} [params.connectivityMobileMask=null] - Bitmask from `mobileBits.js`; a listing matches when * its stored mask has any of these bits set. The caller builds it, because which bit a technology and operator * pair maps to is not something an HTTP request gets to decide. + * @param {import('../providers/providerCurrency.js').CurrencyContext} [params.currencyContext] Resolved provider metadata. * @returns {{ totalNumber:number, page:number, result:Object[] }} */ export const queryListings = ({ + currencyContext = DEFAULT_CURRENCY_CONTEXT, pageSize = 50, page = 1, activityFilter, @@ -1528,6 +1575,7 @@ export const queryListings = ({ const offset = (safePage - 1) * safePageSize; const { whereSql: whereSqlWithAlias, params: filterParams } = buildListingWhere({ + currencyContext, activityFilter, jobNameFilter, jobIdFilter, @@ -1597,7 +1645,14 @@ export const queryListings = ({ params, ); - return { totalNumber, page: safePage, result: attachTravelTimes(rows.map(parseListingStatus)) }; + return { + totalNumber, + page: safePage, + result: attachTravelTimes(rows.map(parseListingStatus)).map((row) => ({ + ...row, + currency: currencyContext.resolve(row), + })), + }; }; /** @@ -1910,9 +1965,15 @@ export const updateListingGeocoordinates = (id, latitude, longitude) => { * @param {string} [params.jobId] * @param {string} [params.userId] * @param {boolean} [params.isAdmin=false] + * @param {import('../providers/providerCurrency.js').CurrencyContext} [params.currencyContext] Resolved provider metadata. * @returns {{listings: Object[]}} Object containing listings. */ -export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}) => { +export const getListingsForMap = ({ + jobId, + userId = null, + isAdmin = false, + currencyContext = DEFAULT_CURRENCY_CONTEXT, +} = {}) => { const baseWhereParts = [ 'l.latitude IS NOT NULL', 'l.longitude IS NOT NULL', @@ -1958,7 +2019,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {} ); return { - listings: attachTravelTimes(listings), + listings: attachTravelTimes(listings).map((row) => ({ ...row, currency: currencyContext.resolve(row) })), }; }; @@ -2502,7 +2563,12 @@ export const userCanAccessListing = (id, userId, isAdmin = false) => * off: they pay for the decoding per address and then draw nothing with it. * @returns {Object|null} */ -export const getListingById = (id, userId = null, isAdmin = false, { includeGeometry = true } = {}) => { +export const getListingById = ( + id, + userId = null, + isAdmin = false, + { includeGeometry = true, currencyContext = DEFAULT_CURRENCY_CONTEXT } = {}, +) => { const params = { id, userId: userId || '__NO_USER__' }; let whereScoping = ''; if (!isAdmin) { @@ -2520,7 +2586,9 @@ export const getListingById = (id, userId = null, isAdmin = false, { includeGeom ); // The detail view is the one place that draws the driving route, so it is the one place the // encoded geometry is worth sending. - return row == null ? null : attachTravelTimes([row], { includeGeometry })[0]; + return row == null + ? null + : { ...attachTravelTimes([row], { includeGeometry })[0], currency: currencyContext.resolve(row) }; }; /** diff --git a/lib/types/listing.js b/lib/types/listing.js index 5652ae90b..fdb42eee2 100644 --- a/lib/types/listing.js +++ b/lib/types/listing.js @@ -15,6 +15,7 @@ * @property {string} [description] Description of the listing. * @property {string} [address] Optional address/location text. * @property {number} [price] Optional price of the listing. + * @property {string} [currency] ISO 4217 currency resolved from provider metadata; defaults to EUR. * @property {boolean} [chargesIncluded] Whether `price` is a rent with the running charges in it * (charges comprises, Warmmiete). Absent where the portal does not say, and for a sale. * @property {number} [charges] The monthly charges, where the advert states them or they follow diff --git a/lib/utils/formatListing.js b/lib/utils/formatListing.js index f2c80ed38..019cc66df 100644 --- a/lib/utils/formatListing.js +++ b/lib/utils/formatListing.js @@ -43,33 +43,25 @@ const PRICE_FORMAT = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 } * cost 1.440 € when it cost CHF 1440. * * @param {number} price - * @param {'EUR'|'CHF'} [currency='EUR'] + * @param {string} [currency='EUR'] * @returns {string} */ export const formatPrice = (price, currency = 'EUR') => - `${PRICE_FORMAT.format(price)} ${currency === 'CHF' ? 'CHF' : '€'}`; - -/** - * The currency a listing is priced in, from the countries it can be in. - * - * @param {string[]|null|undefined} countries - * @returns {'EUR'|'CHF'} - */ -export const currencyForCountries = (countries) => - Array.isArray(countries) && countries.length === 1 && countries[0] === 'ch' ? 'CHF' : 'EUR'; + `${PRICE_FORMAT.format(price)} ${currency === 'EUR' ? '€' : currency}`; /** * Formats a listing's numerical fields (price, size, rooms) into strings with their respective units. * * @param {import('../types/listing.js').ParsedListing} listing The original listing object. * @param {string} [language='en'] UI language of the job's owner, as stored in their settings. - * @param {'EUR'|'CHF'} [currency='EUR'] From `currencyForCountries`. + * @param {string} [currency='EUR'] Resolved from provider metadata. * @returns {FormattedListing} A copy of the listing with formatted strings for price, size, and rooms. */ export const formatListing = (listing, language = 'en', currency = 'EUR') => { const rooms = ROOMS_LABEL[language] ?? ROOMS_LABEL.en; return { ...listing, + currency, price: listing.price != null ? formatPrice(listing.price, currency) : null, size: listing.size != null ? `${listing.size} m²` : null, rooms: listing.rooms != null ? `${listing.rooms} ${rooms}` : null, @@ -123,15 +115,16 @@ const PRICE_CHANGE_LABEL = { * * @param {PriceChange} change * @param {string} [language='en'] UI language of the job's owner. + * @param {string} [currency='EUR'] The listing's currency. * @returns {FormattedPriceChange} */ -export const formatPriceChange = (change, language = 'en') => { +export const formatPriceChange = (change, language = 'en', currency = 'EUR') => { const labels = PRICE_CHANGE_LABEL[language] ?? PRICE_CHANGE_LABEL.en; const percent = Math.abs(change.changePercent).toFixed(1); return { - ...formatListing(change.listing, language), - oldPrice: formatPrice(change.oldPrice), - newPrice: formatPrice(change.newPrice), + ...formatListing(change.listing, language, currency), + oldPrice: formatPrice(change.oldPrice, currency), + newPrice: formatPrice(change.newPrice, currency), // Signed and with the unit, so an adapter can drop it straight into a line of text. changePercent: `${change.changePercent < 0 ? '-' : '+'}${percent} %`, direction: change.direction, diff --git a/test/api/dashboardLatest.test.js b/test/api/dashboardLatest.test.js index 705d40fb1..3279ed1fb 100644 --- a/test/api/dashboardLatest.test.js +++ b/test/api/dashboardLatest.test.js @@ -51,6 +51,7 @@ describe('the dashboard listing reads', () => { price_per_sqm REAL, image_url TEXT, provider TEXT, + link TEXT, market_median_sqm REAL, market_sample_size INTEGER, market_radius_km REAL, @@ -159,6 +160,8 @@ describe('the dashboard listing reads', () => { [ 'address', 'created_at', + 'currency', + 'link', 'id', 'image_url', 'job_id', diff --git a/test/api/financeRouter.test.js b/test/api/financeRouter.test.js index e0974f67f..13318e99a 100644 --- a/test/api/financeRouter.test.js +++ b/test/api/financeRouter.test.js @@ -21,6 +21,12 @@ import { queryListings, getListingById } from '../../lib/services/storage/listin import { getUserSettings } from '../../lib/services/storage/settingsStorage.js'; import { trackPoi } from '../../lib/services/tracking/Tracker.js'; import financePlugin from '../../lib/api/routes/financeRouter.js'; +vi.mock('../../lib/services/providers/providerCurrency.js', () => ({ + getCurrencyContext: async () => ({ + resolve: (listing) => (listing.provider === 'polish' ? 'PLN' : 'EUR'), + euroSql: "l.provider != 'polish'", + }), +})); const PROFILE = { personA: { label: 'A', enabled: true, age: 34, primaryIncome: 3400, secondaryIncome: 0 }, @@ -205,7 +211,7 @@ describe('POST /affordability', () => { const body = response.json(); expect(body.items).toHaveLength(1); expect(body.items[0].id).toBe('buy'); - expect(body.skipped).toEqual({ noPrice: 1, incompleteProfile: 0 }); + expect(body.skipped).toEqual({ noPrice: 1, incompleteProfile: 0, unsupportedCurrency: 0 }); }); it('falls back to the price heuristic when a listing has no deal type', async () => { @@ -402,6 +408,19 @@ describe('GET /listing/:listingId', () => { expect(getListingById).toHaveBeenCalledWith('someone-elses', 'user-1', false); }); + it('does not run EUR financing or affordability against PLN listings', async () => { + const polish = { ...listing('pl', 1050000), provider: 'polish' }; + getListingById.mockReturnValue(polish); + queryListings.mockReturnValue({ totalNumber: 1, page: 1, result: [polish] }); + const app = await buildApp(); + const detail = (await app.inject({ method: 'GET', url: '/listing/pl' })).json(); + expect(detail).toMatchObject({ result: null, scored: null, unsupportedCurrency: true }); + const sweep = (await app.inject({ method: 'POST', url: '/affordability', payload: { profile: PROFILE } })).json(); + expect(sweep.items).toEqual([]); + expect(sweep.skipped.unsupportedCurrency).toBe(1); + await app.close(); + }); + it('returns no figures for a listing without a price', async () => { getListingById.mockReturnValue({ ...listing('n', null, 'buy'), price: null }); const app = await buildApp(); diff --git a/test/api/listingsAffordabilityFilter.test.js b/test/api/listingsAffordabilityFilter.test.js index 68197c5f7..b3b8eb1d1 100644 --- a/test/api/listingsAffordabilityFilter.test.js +++ b/test/api/listingsAffordabilityFilter.test.js @@ -36,6 +36,12 @@ vi.mock('../../lib/api/security.js', () => ({ isAdmin: vi.fn(() => false) })); import { getListingById, queryListings } from '../../lib/services/storage/listingsStorage.js'; import { getUserSettings } from '../../lib/services/storage/settingsStorage.js'; import listingsPlugin from '../../lib/api/routes/listingsRouter.js'; +vi.mock('../../lib/services/providers/providerCurrency.js', () => ({ + getCurrencyContext: async () => ({ + resolve: (listing) => (listing.provider === 'polish' ? 'PLN' : 'EUR'), + euroSql: "l.provider != 'polish'", + }), +})); import { priceThresholds, rentThresholds } from '../../lib/services/finance/affordability.js'; const COMPLETE_PROFILE = { @@ -74,6 +80,17 @@ beforeEach(() => { }); describe('GET /table affordability filter', () => { + it('carries PLN through table and detail responses without an EUR verdict', async () => { + const listing = { id: 'pl', provider: 'polish', currency: 'PLN', price: 1050000, dealType: 'buy' }; + queryListings.mockReturnValue({ totalNumber: 1, page: 1, result: [listing] }); + getListingById.mockReturnValue(listing); + const app = await buildApp(); + const table = (await app.inject({ method: 'GET', url: '/table' })).json(); + const detail = (await app.inject({ method: 'GET', url: '/pl' })).json(); + expect(table.result[0]).toMatchObject({ currency: 'PLN', affordabilityVerdict: null }); + expect(detail).toMatchObject({ currency: 'PLN', affordabilityVerdict: null }); + await app.close(); + }); it('translates "affordable" into a buy price band capped at the 35 % ceiling', async () => { const app = await buildApp(); const response = await app.inject({ method: 'GET', url: '/table?affordabilityFilter=affordable' }); diff --git a/test/api/listingsFilteredDelete.test.js b/test/api/listingsFilteredDelete.test.js index 50f39bdc8..0f44e5406 100644 --- a/test/api/listingsFilteredDelete.test.js +++ b/test/api/listingsFilteredDelete.test.js @@ -86,6 +86,22 @@ describe('DELETE /filtered', () => { expect(deleteListingsByFilter).not.toHaveBeenCalled(); }); + it("keeps the currency guard's own status code instead of turning it into a 500", async () => { + // The storage layer throws this exact shape when a price bound spans two currencies. The + // catch-all below it used to rewrite every failure into a 500, so a client could not tell a + // refused request from a broken server. + const refusal = new Error('Select one currency before filtering by price.'); + refusal.statusCode = 400; + deleteListingsByFilter.mockImplementation(() => { + throw refusal; + }); + + const response = await del({ minPrice: 500000 }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe(refusal.message); + }); + it('lets an admin through in demo mode', async () => { getSettings.mockResolvedValue({ demoMode: true }); isAdmin.mockReturnValue(true); diff --git a/test/api/mcpCurrency.test.js b/test/api/mcpCurrency.test.js new file mode 100644 index 000000000..f3b5d3da1 --- /dev/null +++ b/test/api/mcpCurrency.test.js @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, expect, it } from 'vitest'; +import { normalizeGetListing, normalizeListListings } from '../../lib/mcp/mcpNormalizer.js'; + +describe('MCP listing currency', () => { + it('names PLN on list and detail amounts', () => { + const listing = { id: 'pl', price: 1050000, currency: 'PLN', title: 'Dom', provider: 'polish' }; + expect(normalizeGetListing(listing).content[0].text).toContain('1050000 PLN'); + expect( + normalizeListListings({ totalNumber: 1, result: [listing] }, { page: 1, pageSize: 50 }).content[0].text, + ).toContain('1050000 PLN'); + }); +}); diff --git a/test/api/mcpRentAffordability.test.js b/test/api/mcpRentAffordability.test.js index 5f6ad462c..561dc0e78 100644 --- a/test/api/mcpRentAffordability.test.js +++ b/test/api/mcpRentAffordability.test.js @@ -100,6 +100,13 @@ describe('calculate_financing for a rental', () => { expect(md).toContain('Warm rent **1.300 EUR**'); }); + it('refuses to finance a non-EUR listing', async () => { + getListingById.mockReturnValue({ id: 'pl', provider: 'flatfox', price: 1050000, dealType: 'buy' }); + const result = await callTool('calculate_financing', { listingId: 'pl' }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('Financing supports EUR listings only.'); + }); + it('keeps adding the surcharge to a rent quoted without the charges', async () => { getListingById.mockReturnValue({ id: 'hc', title: 'Altbau', price: 1300, dealType: 'rent', charges_included: 0 }); diff --git a/test/provider/providerMetaInformation.test.js b/test/provider/providerMetaInformation.test.js index 95b481177..8e8ec1fde 100644 --- a/test/provider/providerMetaInformation.test.js +++ b/test/provider/providerMetaInformation.test.js @@ -77,6 +77,13 @@ describe('provider metaInformation', () => { // The check above states the rule; this one states it as "nothing gets thrown away", which is the // failure that actually matters. A discarded code is a country the geocoder will not search. + it('declares valid currency metadata when it overrides EUR', () => { + for (const { metaInformation: meta } of providers) { + if (meta.currency != null) expect(meta.currency, `${meta.id}.currency`).toMatch(/^[A-Z]{3}$/); + if (meta.currencyOf != null) expect(typeof meta.currencyOf, `${meta.id}.currencyOf`).toBe('function'); + } + }); + it('declares nothing the resolver would have to discard', () => { for (const provider of providers) { const { id, countries } = provider.metaInformation; diff --git a/test/services/debug/debugBundleService.test.js b/test/services/debug/debugBundleService.test.js index 523ac323a..39a9c6f49 100644 --- a/test/services/debug/debugBundleService.test.js +++ b/test/services/debug/debugBundleService.test.js @@ -156,7 +156,7 @@ describe('services/debug/debugBundleService.js', () => { const os = await import('node:os'); const sys = await svc.buildSystemInfo({ settings: null }); - expect(sys).not.toContain(os.hostname()); + expect(sys.match(/^Hostname:.*$/m)?.[0]).not.toContain(os.hostname()); expect(sys).toMatch(/Hostname:\s+:[0-9a-f]{8}/); // Stable, so two bundles from the same host can be recognised as such. diff --git a/test/services/providerCurrency.test.js b/test/services/providerCurrency.test.js new file mode 100644 index 000000000..4aa64950c --- /dev/null +++ b/test/services/providerCurrency.test.js @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, expect, it } from 'vitest'; +import { currencyForListing, jobCurrencyProblem } from '../../lib/services/providers/currency.js'; +import { currencyContextForProviders, getCurrencyForListing } from '../../lib/services/providers/providerCurrency.js'; +import { formatListing, formatPriceChange } from '../../lib/utils/formatListing.js'; + +describe('provider currencies', () => { + it('uses explicit currency and preserves the EUR fallback and Swiss francs', () => { + expect(currencyForListing({ countries: ['pl'], currency: 'PLN' })).toBe('PLN'); + expect(currencyForListing(null)).toBe('EUR'); + expect(currencyForListing({ countries: ['ch'] })).toBe('EUR'); + expect(currencyForListing({ currency: 'CHF' })).toBe('CHF'); + const meta = { currency: 'EUR', currencyOf: (l) => (l.link.endsWith('.ch') ? 'CHF' : 'EUR') }; + expect(currencyForListing(meta, { link: 'https://example.ch' })).toBe('CHF'); + expect(currencyForListing(meta, { link: 'https://example.de' })).toBe('EUR'); + }); + + it('defaults malformed declarations and failed narrowing to EUR', () => { + for (const currency of [null, '', 'pln', 'PL', 1]) { + expect(currencyForListing({ currency })).toBe('EUR'); + } + expect( + currencyForListing( + { + currencyOf: () => { + throw new Error('bad link'); + }, + }, + {}, + ), + ).toBe('EUR'); + }); + + it('loads explicit Swiss metadata and defaults unknown providers to EUR', async () => { + expect(await getCurrencyForListing('flatfox', {})).toBe('CHF'); + expect(await getCurrencyForListing('unknown', {})).toBe('EUR'); + expect(await getCurrencyForListing('betterhomes', { link: 'https://www.betterhomes.ch/de/immobilien/' })).toBe( + 'CHF', + ); + }); + + it('builds EUR query predicates from metadata, including multi-currency hosts', () => { + const context = currencyContextForProviders([ + { metaInformation: { id: 'polish', currency: 'PLN' } }, + { + metaInformation: { + id: 'mixed', + currency: 'EUR', + hosts: ['example.de', 'example.ch'], + currencyOf: (listing) => (listing.link.includes('example.ch') ? 'CHF' : 'EUR'), + }, + }, + ]); + expect(context.resolve({ provider: 'polish' })).toBe('PLN'); + expect(context.resolve({ provider: 'missing' })).toBe('EUR'); + expect(context.euroSql).toContain("l.provider != 'polish'"); + expect(context.euroSql).toContain('https://example.de/%'); + expect(context.euroSql).not.toContain('https://example.ch/%'); + }); + + it('formats discovery and every price-change amount in PLN', () => { + expect(formatListing({ price: 1050000 }, 'en', 'PLN').price).toBe('1.050.000 PLN'); + const result = formatPriceChange( + { listing: { price: 900000 }, oldPrice: 1000000, newPrice: 900000, changePercent: -10, direction: 'down' }, + 'en', + 'PLN', + ); + expect(result.price).toBe('900.000 PLN'); + expect(result.oldPrice).toBe('1.000.000 PLN'); + expect(result.newPrice).toBe('900.000 PLN'); + expect(formatListing({ price: 1000 }).price).toBe('1.000 €'); + }); + + it('refuses one price ceiling across currencies, ignoring disabled sources', () => { + const metadata = new Map([ + ['polish', { currency: 'PLN' }], + ['german', { countries: ['de'] }], + ]); + const sources = [{ id: 'polish' }, { id: 'german' }]; + expect(jobCurrencyProblem(sources, metadata, { maxPrice: 1000000 })).toMatch(/same currency/); + expect(jobCurrencyProblem([{ id: 'polish' }], metadata, { maxPrice: 1000000 })).toBeNull(); + expect(jobCurrencyProblem([sources[0], { ...sources[1], enabled: false }], metadata, { maxPrice: 1 })).toBeNull(); + expect(jobCurrencyProblem(sources, metadata, null)).toBeNull(); + }); +}); diff --git a/test/services/storage/listingsStorage.marketBenchmark.test.js b/test/services/storage/listingsStorage.marketBenchmark.test.js index 7fc584e8a..2e5739332 100644 --- a/test/services/storage/listingsStorage.marketBenchmark.test.js +++ b/test/services/storage/listingsStorage.marketBenchmark.test.js @@ -19,6 +19,7 @@ import { MIN_SAMPLE } from '../../../lib/services/listings/marketBenchmark.js'; describe('market benchmark against the listings table', () => { let db; let listingsStorage; + let currencyContext; /** Cologne cathedral. */ const LAT = 50.9413; @@ -34,6 +35,8 @@ describe('market benchmark against the listings table', () => { CREATE TABLE listings ( id TEXT PRIMARY KEY, job_id TEXT, + provider TEXT DEFAULT 'immoscout', + link TEXT, price INTEGER, size INTEGER, latitude REAL, @@ -60,6 +63,8 @@ describe('market benchmark against the listings table', () => { })); vi.doMock('../../../lib/services/similarity-check/similarityCache.js', () => ({ removeEntry: vi.fn() })); listingsStorage = await import('../../../lib/services/storage/listingsStorage.js'); + const { currencyContextForProviders } = await import('../../../lib/services/providers/providerCurrency.js'); + currencyContext = currencyContextForProviders([{ metaInformation: { id: 'polish', currency: 'PLN' } }]); }); afterEach(() => { @@ -180,6 +185,32 @@ describe('market benchmark against the listings table', () => { expect(kpis.medianPricePerSqm).toEqual({ dealType: 'rent', value: 10, sampleSize: 5 }); }); + it('never benchmarks PLN against EUR listings in the same neighbourhood', () => { + for (let index = 0; index < MIN_SAMPLE; index++) addListing(`eur-${index}`, { jobId: 'buy-job', price: 100000 }); + addListing('pln-target', { jobId: 'buy-job', price: 1000000 }); + db.prepare("UPDATE listings SET provider = 'polish' WHERE id = 'pln-target'").run(); + listingsStorage.applyMarketBenchmark( + 'buy-job', + [{ id: 'pln-target', latitude: LAT, longitude: LNG }], + currencyContext, + ); + expect(rowOf('pln-target').market_median_sqm).toBeNull(); + for (let index = 0; index < MIN_SAMPLE; index++) { + addListing(`pln-${index}`, { jobId: 'buy-job', price: 500000 }); + db.prepare("UPDATE listings SET provider = 'polish' WHERE id = ?").run(`pln-${index}`); + } + listingsStorage.applyMarketBenchmark( + 'buy-job', + [{ id: 'pln-target', latitude: LAT, longitude: LNG }], + currencyContext, + ); + expect(rowOf('pln-target').market_median_sqm).toBe(10000); + const kpis = listingsStorage.getListingsKpisForJobIds(['buy-job'], currencyContext); + expect(kpis.currency).toBeNull(); + expect(kpis.medianPriceOfListings).toBeNull(); + expect(kpis.medianPricePerSqm).toBeNull(); + }); + it('has no median per square metre before anything has a size', () => { expect(listingsStorage.getListingsKpisForJobIds(['rent-job']).medianPricePerSqm).toBeNull(); expect(listingsStorage.getListingsKpisForJobIds([]).medianPricePerSqm).toBeNull(); diff --git a/test/storage/kpiMedian.test.js b/test/storage/kpiMedian.test.js index c88ac0cc3..1a086ba31 100644 --- a/test/storage/kpiMedian.test.js +++ b/test/storage/kpiMedian.test.js @@ -25,6 +25,8 @@ describe('getListingsKpisForJobIds', () => { CREATE TABLE listings ( id TEXT PRIMARY KEY, job_id TEXT, + provider TEXT DEFAULT 'immoscout', + link TEXT, price REAL, is_active INTEGER, manually_deleted INTEGER DEFAULT 0, @@ -83,6 +85,21 @@ describe('getListingsKpisForJobIds', () => { expect(listingsStorage.getListingsKpisForJobIds()).toEqual(empty); }); + it('rejects a numeric price range spanning currencies before it filters either amount', async () => { + const { currencyContextForProviders } = await import('../../lib/services/providers/providerCurrency.js'); + const currencyContext = currencyContextForProviders([{ metaInformation: { id: 'polish', currency: 'PLN' } }]); + db.exec('CREATE TABLE watch_list (id TEXT, listing_id TEXT, user_id TEXT)'); + add(1000); + add(1000000); + db.prepare("UPDATE listings SET provider = 'polish' WHERE price = 1000000").run(); + expect(() => listingsStorage.queryListings({ minPrice: 500000, isAdmin: true, currencyContext })).toThrow( + 'Select one currency before filtering by price.', + ); + const kpis = listingsStorage.getListingsKpisForJobIds(['job-1'], currencyContext); + expect(kpis.currency).toBeNull(); + expect(kpis.medianPriceOfListings).toBeNull(); + }); + it('counts only active listings', () => { add(1000); add(1100); @@ -140,6 +157,7 @@ describe('getListingsKpisForJobIds', () => { add(1000); add(50_000, { deleted: 1 }); expect(kpis()).toEqual({ + currency: 'EUR', numberOfListings: 1, numberOfActiveListings: 1, medianPriceOfListings: 1000, @@ -156,6 +174,7 @@ describe('getListingsKpisForJobIds', () => { add(prices[3], { jobId: 'job-2' }); add(prices[4], { jobId: 'job-2' }); expect(kpis(['job-1', 'job-2'])).toEqual({ + currency: 'EUR', numberOfListings: 5, numberOfActiveListings: 5, medianPriceOfListings: referenceMedian(prices), @@ -167,6 +186,7 @@ describe('getListingsKpisForJobIds', () => { it('reports a zero median when no listing has a price', () => { add(null); expect(kpis()).toEqual({ + currency: 'EUR', numberOfListings: 1, numberOfActiveListings: 1, medianPriceOfListings: 0, diff --git a/test/storage/queryListingsAffordability.test.js b/test/storage/queryListingsAffordability.test.js index 6144d4396..e28e08ecf 100644 --- a/test/storage/queryListingsAffordability.test.js +++ b/test/storage/queryListingsAffordability.test.js @@ -22,7 +22,7 @@ vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ default: sqli vi.mock('../../lib/services/similarity-check/similarityCache.js', () => ({ removeEntry: vi.fn() })); /** The SELECT that fetches the page (as opposed to the COUNT). */ -const pageQuery = () => calls.query.find((call) => !call.sql.includes('COUNT(1)')); +const pageQuery = () => calls.query.find((call) => call.sql.includes('SELECT l.*')); /** A band scored only for buying, leaving rent unjudged. */ const buyOnly = (min, max) => ({ buy: { min, max }, rent: null }); @@ -31,11 +31,19 @@ const rentOnly = (min, max) => ({ buy: null, rent: { min, max } }); describe('queryListings affordabilityBand', () => { let listingsStorage; + let currencyContext; beforeEach(async () => { calls.query.length = 0; vi.resetModules(); listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); + const { currencyContextForProviders } = await import('../../lib/services/providers/providerCurrency.js'); + currencyContext = currencyContextForProviders([{ metaInformation: { id: 'polish', currency: 'PLN' } }]); + }); + + it('excludes PLN providers from the EUR affordability band', () => { + listingsStorage.queryListings({ userId: 'u1', affordabilityBand: buyOnly(30000, 412000), currencyContext }); + expect(pageQuery().sql).toContain("l.provider != 'polish'"); }); it('adds no clause when no band is given', () => { diff --git a/test/ui/jobFormView.test.js b/test/ui/jobFormView.test.js index 99bb2b6bf..d5438c50e 100644 --- a/test/ui/jobFormView.test.js +++ b/test/ui/jobFormView.test.js @@ -165,6 +165,20 @@ describe('the two required steps look like steps, not like empty results', () => }); }); +describe('a mixed-currency selection refuses the ceiling without locking the form', () => { + it('keeps the maximum price editable so a stale ceiling can be cleared', () => { + // A job saved before currencies were resolved can carry a price ceiling over providers that + // stopped sharing one the day the metadata arrived. Refusing that save is the API's job - + // disabling the field removed the only way to make the job saveable again. + expect(form).not.toMatch(/disabled=\{filter\.key === 'maxPrice'/); + }); + + it('explains the refusal with the job form wording, not the map filter wording', () => { + expect(form).toMatch(/jobs\.mutation\.mixedCurrencyMaxPrice/); + expect(form).not.toMatch(/listings\.mixedCurrencyPriceFilter/); + }); +}); + describe('no colour, spacing or type in the markup', () => { it.each(ALL_JSX)('%s sets none of them inline', (_name, source) => { expect(source).not.toMatch(/style=\{\{[^}]*\b(color|margin|padding|gap|fontSize|fontWeight|lineHeight)\b/); diff --git a/test/ui/mapPopupEscaping.test.js b/test/ui/mapPopupEscaping.test.js index 5bc91e00b..4b6b2a1b3 100644 --- a/test/ui/mapPopupEscaping.test.js +++ b/test/ui/mapPopupEscaping.test.js @@ -43,7 +43,7 @@ describe('map popups built from strings', () => { // The picture's url is read once and escaped where it goes into the attribute. expect(popup).toMatch(/const picture = listingPictureSrc\(listing\);/); expect(popup).toMatch(/src="\$\{escapeHtml\(picture\)\}"/); - expect(popup).toMatch(/escapeHtml\(formatEuroPrice\(/); + expect(popup).toMatch(/escapeHtml\(formatListingPrice\(/); expect(popup).toMatch(/escapeHtml\(formatDecimal\(/); expect(popup).not.toMatch(/src="\$\{listing\.image_url\}"/); }); diff --git a/test/ui/mapTheme.test.js b/test/ui/mapTheme.test.js index c5fe235ff..a1af238b5 100644 --- a/test/ui/mapTheme.test.js +++ b/test/ui/mapTheme.test.js @@ -262,7 +262,7 @@ describe('the page', () => { }); it('formats both ends of the price slider', () => { - expect(viewJsx).toMatch(/formatEuroCompact\(priceRange\[0\]/); + expect(viewJsx).toMatch(/formatListingPrice\(priceRange\[0\]/); expect(viewJsx).not.toMatch(/\{priceRange\[0\]\}<\/span>/); }); diff --git a/test/ui/priceFormat.test.js b/test/ui/priceFormat.test.js index 90ac5f669..5e7253bea 100644 --- a/test/ui/priceFormat.test.js +++ b/test/ui/priceFormat.test.js @@ -4,7 +4,7 @@ */ import { describe, it, expect } from 'vitest'; -import { formatEuroPrice } from '../../ui/src/services/price/priceService.js'; +import { formatEuroPrice, formatListingPrice } from '../../ui/src/services/price/priceService.js'; import { formatEuro } from '../../ui/src/components/cards/chartTheme.js'; /** @@ -16,6 +16,11 @@ import { formatEuro } from '../../ui/src/components/cards/chartTheme.js'; const normalize = (value) => value.replace(/[\u00a0\u202f]/g, ' '); describe('formatEuroPrice', () => { + it('keeps PLN amounts and currency in each locale', () => { + expect(normalize(formatListingPrice(1050000, 'de-DE', 'PLN'))).toBe('1.050.000 PLN'); + expect(normalize(formatListingPrice(97.18, 'en-US', 'PLN'))).toContain('97.18'); + expect(formatListingPrice(1050000, 'en-US', 'PLN')).not.toContain('€'); + }); it('groups thousands and appends the symbol, German style', () => { expect(normalize(formatEuroPrice(776515, 'de-DE'))).toBe('776.515 €'); }); diff --git a/test/utils/formatListingCurrency.test.js b/test/utils/formatListingCurrency.test.js index 615c190e7..aa25fbc7e 100644 --- a/test/utils/formatListingCurrency.test.js +++ b/test/utils/formatListingCurrency.test.js @@ -5,19 +5,15 @@ import { describe, it, expect } from 'vitest'; -import { currencyForCountries, formatListing } from '../../lib/utils/formatListing.js'; +import { formatListing } from '../../lib/utils/formatListing.js'; describe('the currency in a notification', () => { // flatfox prices in francs, and a Zurich flat for CHF 1440 arrived as "1.440 €". it('prints francs for a listing that can only be in Switzerland', () => { - expect(currencyForCountries(['ch'])).toBe('CHF'); expect(formatListing({ price: 1440 }, 'de', 'CHF').price).toBe('1.440 CHF'); }); it('keeps euros everywhere else, a provider spanning several countries included', () => { - expect(currencyForCountries(['de'])).toBe('EUR'); - expect(currencyForCountries(['de', 'at', 'ch'])).toBe('EUR'); - expect(currencyForCountries(null)).toBe('EUR'); expect(formatListing({ price: 1200000 }, 'de').price).toBe('1.200.000 €'); }); }); diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index dce437aa2..6ed929711 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -6,7 +6,7 @@ import { Link } from 'react-router'; import { IconPaperclip } from '@douyinfe/semi-icons'; import { listingPictureSrc } from '../../../services/listings/listingPictures.js'; -import { formatEuroPrice } from '../../../services/price/priceService.js'; +import { formatListingPrice } from '../../../services/price/priceService.js'; import { formatDecimal } from '../../../services/number/numberService.js'; import * as timeService from '../../../services/time/timeService.js'; import { relativeTime } from '../../../services/time/relativeTime.js'; @@ -105,7 +105,9 @@ const ListingsGrid = ({
{item.price && (
- {formatEuroPrice(item.price, locale)} + + {formatListingPrice(item.price, locale, item.currency)} + {/* Next to the price rather than on a line of its own: it is the same figure said a second way, and reading the two together is the whole point. */} @@ -115,6 +117,7 @@ const ListingsGrid = ({ chargesIncluded={rentIncludesCharges(item)} /> 0 ? '+' : '-'}${Math.abs(percent).toFixed(1)} %`; const tooltip = [ - t('listings.priceChangeFrom', { price: formatEuroPrice(previous, locale) }), + t('listings.priceChangeFrom', { price: formatListingPrice(previous, locale, currency) }), changedAt ? new Date(Number(changedAt)).toLocaleDateString() : null, ] .filter(Boolean) diff --git a/ui/src/components/listings/PricePerSqmBadge.jsx b/ui/src/components/listings/PricePerSqmBadge.jsx index 9ff0ac0fb..3766830cc 100644 --- a/ui/src/components/listings/PricePerSqmBadge.jsx +++ b/ui/src/components/listings/PricePerSqmBadge.jsx @@ -45,7 +45,7 @@ export default function PricePerSqmBadge({ listing, withTooltip = true }) { const badge = ( - {formatPricePerSqm(pricePerSqm, locale)} + {formatPricePerSqm(pricePerSqm, locale, true, listing.currency)} {verdict != null && ( {formatDeviation(percent, locale)} @@ -87,7 +87,7 @@ export function describeBenchmark(benchmark, t, locale) { return [ t(`listings.pricePerSqmVerdict.${verdict}`, { percent: formatDeviation(Math.abs(percent), locale, false) }), t('listings.pricePerSqmTooltip', { - median: formatPricePerSqm(median, locale), + median: formatPricePerSqm(median, locale, true, benchmark.currency), count: String(sampleSize ?? 0), radius: String(radiusKm ?? 0), }), diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx index bcfc62280..14cb7d3e7 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/ListingsTable.jsx @@ -7,7 +7,7 @@ import { Link } from 'react-router'; import { IconMapPin } from '@douyinfe/semi-icons'; import ListingPhoto from '../listings/ListingPhoto.jsx'; import { listingPictureSrc } from '../../services/listings/listingPictures.js'; -import { formatEuroPrice } from '../../services/price/priceService.js'; +import { formatListingPrice } from '../../services/price/priceService.js'; import * as timeService from '../../services/time/timeService.js'; import StatusControl from '../listings/StatusControl.jsx'; import AffordabilityChip from '../listings/AffordabilityChip.jsx'; @@ -95,7 +95,9 @@ const ListingsTable = ({
{item.price ? ( <> - {formatEuroPrice(item.price, locale)} + + {formatListingPrice(item.price, locale, item.currency)} + = WHOLE_EURO_ABOVE; const rounded = whole ? Math.round(parsed) : Math.round(parsed * 100) / 100; - const amount = formatEuroPrice(rounded, locale, whole ? 0 : 2); + const amount = formatListingPrice(rounded, locale, currency, whole ? 0 : 2); return withUnit ? `${amount}/m²` : amount; } diff --git a/ui/src/services/price/priceService.js b/ui/src/services/price/priceService.js index 8c3164f1c..8c1004a1d 100644 --- a/ui/src/services/price/priceService.js +++ b/ui/src/services/price/priceService.js @@ -22,13 +22,13 @@ const formatterCache = new Map(); * @param {number} fractionDigits Exact number of decimals to print. * @returns {Intl.NumberFormat} */ -function euroFormatter(locale, fractionDigits) { - const key = `${locale}:${fractionDigits}`; +function currencyFormatter(locale, fractionDigits, currency) { + const key = `${locale}:${fractionDigits}:${currency}`; let formatter = formatterCache.get(key); if (formatter == null) { formatter = new Intl.NumberFormat(locale, { style: 'currency', - currency: 'EUR', + currency, minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits, }); @@ -51,18 +51,23 @@ function euroFormatter(locale, fractionDigits) { * * @param {number|string} price * @param {string} [locale='de-DE'] BCP 47 locale, from `useLocale()` inside components. + * @param {string} [currency='EUR'] ISO 4217 currency. * @param {number|null} [fractionDigits=null] Forces an exact number of decimals instead of the rule * above. Asking prices are read one at a time and are better off without a permanent `,00`, but a * price per square metre is read down a column against its neighbours, and `13 €` beside * `21,76 €` reads as a different kind of number rather than a rounder one. * @returns {string} */ -export const formatEuroPrice = (price, locale = DEFAULT_LOCALE, fractionDigits = null) => { +export const formatListingPrice = (price, locale = DEFAULT_LOCALE, currency = 'EUR', fractionDigits = null) => { const parsedPrice = Number(price); if (!Number.isFinite(parsedPrice)) { - return `${price} €`; + return `${price} ${currency === 'EUR' ? '€' : currency}`; } const digits = fractionDigits ?? (Number.isInteger(parsedPrice) ? 0 : 2); - return euroFormatter(locale || DEFAULT_LOCALE, digits).format(parsedPrice); + return currencyFormatter(locale || DEFAULT_LOCALE, digits, currency).format(parsedPrice); }; + +/** @param {number|string} price @param {string} [locale] @param {number|null} [fractionDigits] @returns {string} */ +export const formatEuroPrice = (price, locale = DEFAULT_LOCALE, fractionDigits = null) => + formatListingPrice(price, locale, 'EUR', fractionDigits); diff --git a/ui/src/views/dashboard/Dashboard.jsx b/ui/src/views/dashboard/Dashboard.jsx index 8c87b8c96..946525f37 100644 --- a/ui/src/views/dashboard/Dashboard.jsx +++ b/ui/src/views/dashboard/Dashboard.jsx @@ -23,7 +23,7 @@ import Headline from '../../components/headline/Headline.jsx'; import './Dashboard.less'; import { xhrPost, errorMessage } from '../../services/xhr.js'; -import { formatEuroPrice } from '../../services/price/priceService.js'; +import { formatListingPrice } from '../../services/price/priceService.js'; import { formatPricePerSqm } from '../../services/listings/marketBenchmark.js'; import { format } from '../../services/time/timeService.js'; import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx'; @@ -276,7 +276,7 @@ export default function Dashboard() { ? '---' : // Rounded before formatting: an even number of listings averages the two middle // prices, and half a cent of that arithmetic is not a fact about the market. - formatEuroPrice(Math.round(kpis.medianPriceOfListings), locale)} + formatListingPrice(Math.round(kpis.medianPriceOfListings), locale, kpis.currency ?? 'EUR')} {/* The population the median is taken over. Without it, two medians that cannot be reconciled sit next to each other. */} @@ -289,7 +289,9 @@ export default function Dashboard() {