From 5a37c444c9f001ba760a94b9ecc3cf8e0d34fd09 Mon Sep 17 00:00:00 2001 From: Marcin Gajewski Date: Wed, 4 Nov 2026 14:16:31 +0100 Subject: [PATCH] feat: resolve listing currencies from provider metadata and store them on each row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers can now declare the currency they price in, and each listing stores it. Flatfox and betterhomes.ch are CHF, everything else stays EUR. Fredy doesn't convert anything, it just shows the real currency instead of always €. Prices in different currencies no longer get compared: - The dashboard hides its medians for a mix, and says why. - Benchmarks only use listings in the same currency. - A job price ceiling needs sources in one currency. - The map slider and MCP price filters need one currency. `list_listings` gets a `currency` argument. - Finance and price tracking stay EUR only. The pipeline writes `listings.currency` at insert (NOT NULL, so a forgotten writer fails rather than guessing), and migration 54 backfills flatfox and betterhomes.ch. Every check is a comparison on that column. Near a border a stored benchmark could have blended two currencies, so the migration measures those listings again. - Declare per-host exceptions as data (`currencyByHost`), replacing `currencyOf` - Pass the benchmark scope in explicitly, which also keeps migration 41 working on a fresh install - Filter the finance sweep by currency in the query, so other currencies cannot use up its cap - Check price bounds outside `buildListingWhere` and let routes and MCP choose the answer; add a `currency` argument to `list_listings` - Say why dashboard medians are hidden for mixed currencies - Price application letters in the listing's currency - Skip price observations outside the euro - Keep the compact and rounded price formats in other currencies - Do not offer the finance calculator on non-EUR listings - Ignore unpriced listings when deciding a map selection is mixed, and do not count a price bound the map is not applying --- AGENTS.md | 7 +- CONTRIBUTING.md | 21 +- doc/providers.md | 13 ++ lib/FredyPipelineExecutioner.js | 19 +- lib/api/routes/dashboardRouter.js | 2 + lib/api/routes/financeRouter.js | 9 +- lib/api/routes/jobRouter.js | 4 +- lib/api/routes/listingsRouter.js | 8 +- lib/errors.js | 9 +- lib/mcp/jobDraftContext.js | 9 +- lib/mcp/jobDraftStore.js | 16 +- lib/mcp/mcpAdapter.js | 73 +++++-- lib/mcp/mcpNormalizer.js | 4 +- lib/provider/betterhomes.js | 2 + lib/provider/flatfox.js | 1 + lib/services/application/placeholders.js | 10 +- lib/services/listings/marketBenchmark.js | 21 +- lib/services/listings/priceHistoryService.js | Bin 7560 -> 7563 bytes lib/services/providers/currency.js | 52 +++++ lib/services/providers/providerCurrency.js | 22 ++ lib/services/storage/listingsStorage.js | 131 +++++++++--- .../migrations/sql/55.listing-currency.js | 109 ++++++++++ lib/services/tour/tourService.js | 2 + lib/services/tracking/Tracker.js | 6 +- lib/types/listing.js | 1 + lib/types/providerConfig.js | 2 + lib/utils/formatListing.js | 23 +-- test/api/dashboardLatest.test.js | 2 + test/api/financeRouter.test.js | 17 ++ test/api/listingsAffordabilityFilter.test.js | 12 ++ test/api/listingsFilteredDelete.test.js | 15 ++ test/api/mcpCurrency.test.js | 25 +++ test/api/mcpRentAffordability.test.js | 51 ++++- test/pipeline_ingest.test.js | 25 +++ test/pipeline_priceChange.test.js | 12 ++ test/provider/providerMetaInformation.test.js | 10 + .../services/application/placeholders.test.js | 7 + .../services/debug/debugBundleService.test.js | 2 +- .../services/listings/marketBenchmark.test.js | 16 ++ test/services/providerCurrency.test.js | 83 ++++++++ .../listingsStorage.marketBenchmark.test.js | 44 +++- .../listingsStorage.publishedAt.test.js | 2 + .../storage/listingsStorage.scam.test.js | 2 + .../tracking/priceObservation.test.js | 7 +- test/storage/kpiMedian.test.js | 53 ++++- test/storage/listingCurrencyMigration.test.js | 189 ++++++++++++++++++ test/storage/listingMediaStorage.test.js | 2 + test/storage/priceHistoryStorage.test.js | 15 ++ .../queryListingsAffordability.test.js | 9 +- .../queryListingsAffordabilitySql.test.js | 40 ++++ test/storage/storeListingsCharges.test.js | 2 + test/storage/storeListingsConflict.test.js | 17 ++ test/ui/dashboardLayout.test.js | 12 ++ test/ui/jobFormView.test.js | 14 ++ test/ui/listingCurrency.test.js | 37 ++++ test/ui/mapPopupEscaping.test.js | 2 +- test/ui/mapTheme.test.js | 2 +- test/ui/priceFormat.test.js | 48 +++-- test/utils/formatListingCurrency.test.js | 8 +- ui/src/components/cards/chartTheme.js | 14 +- .../components/grid/listings/ListingsGrid.jsx | 7 +- .../components/listings/PriceChangeBadge.jsx | 7 +- .../components/listings/PricePerSqmBadge.jsx | 4 +- ui/src/components/table/ListingsTable.jsx | 7 +- ui/src/locales/de.json | 6 +- ui/src/locales/en.json | 6 +- ui/src/locales/es.json | 6 +- ui/src/locales/fr.json | 6 +- ui/src/locales/it.json | 6 +- ui/src/locales/tr.json | 6 +- ui/src/services/listings/marketBenchmark.js | 8 +- ui/src/services/number/numberService.js | 4 +- ui/src/services/price/priceService.js | 14 +- ui/src/views/dashboard/Dashboard.jsx | 23 ++- .../dashboard/components/LatestListings.jsx | 8 +- ui/src/views/jobs/mutation/JobMutation.jsx | 26 ++- ui/src/views/listings/ListingDetail.jsx | 4 +- ui/src/views/listings/Map.jsx | 38 +++- .../components/ListingFinanceCard.jsx | 4 + .../listings/components/ListingKeyFacts.jsx | 2 +- .../listings/components/PriceHistoryChart.jsx | 9 +- ui/src/views/listings/listingFacts.js | 15 +- ui/src/views/listings/listingPopupContent.jsx | 6 +- 83 files changed, 1385 insertions(+), 209 deletions(-) create mode 100644 lib/services/providers/currency.js create mode 100644 lib/services/providers/providerCurrency.js create mode 100644 lib/services/storage/migrations/sql/55.listing-currency.js create mode 100644 test/api/mcpCurrency.test.js create mode 100644 test/services/providerCurrency.test.js create mode 100644 test/storage/listingCurrencyMigration.test.js create mode 100644 test/ui/listingCurrency.test.js diff --git a/AGENTS.md b/AGENTS.md index a93366abf..daffbdeb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,12 @@ scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run geocoder searches within the resolved countries; the map's `maxBounds` is the union of their boxes from `ui/src/components/map/countryBounds.js`. Where no provider exists to ask - home addresses, the listings map, the listing detail - the answer is the union across the jobs the user - can see, and where the job form is open it is the providers ticked in it + can see, and where the job form is open it is the providers ticked in it. An optional `currency` + (ISO 4217, absent means `EUR`) and `currencyByHost` (the exceptions, for a provider whose national + sites price differently) are read once, when the pipeline stores a listing; after that + `listings.currency` is the only source, because comparisons happen in SQL. `currency.js` in the + same directory holds the resolver. A row without one fails on the `NOT NULL` column by design, so + every writer has to set it - `config` - the **static** `ProviderConfig` template: `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, optional `getListings()`, `fetchDetails()`, `activityProbe()`, and `needsBrowser: false` on a provider whose search and details never touch the browser (ImmoScout's mobile API, flatfox, the tecnocasa network), so a job run or detail fetch with only such providers starts no Chromium. `url` is `null` here and there is no bound `filter`. A portal whose pages only answer a browser session (DataDome answers node with 403 whether an advert is online or not - immowelt, SeLoger) declares `browserActivityProbe(link, browser)` and `priceTracking.browserProbe(listing, browser)` instead; the alive-checker and the price tracker start one browser per run for them, and only when a due listing needs it. - `createConfig(sourceConfig, blacklist)` - returns a **fresh** `ProviderConfig` per job run: the template plus this run's `url`, `enabled`, and a `filter` closed over this run's blacklist. - Optional `fetchListingByUrl(url, browser)` on the static template - reads one advert's page into a complete `ParsedListing`, or `null` when the page is no single advert or could not be read. It is what "Add listing" in the listings view calls. Build the provider's own search-card shape from the exposé and pass it through its `normalize`, so `link` and hash come out as a search run would produce them: a later run of the job recognises the stored row by both, and a different spelling stores the advert twice. The `/api/jobs/provider` response carries `addByUrl` per provider so the dialog can say "not supported yet" before anything is sent. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b589a2757..4b737d9c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,12 +108,21 @@ 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`. A provider whose national sites price differently +lists the exceptions by host, for example `currencyByHost: { 'betterhomes.ch': 'CHF' }`. That is +data rather than a function like `countryOf`, because the job form has to read it in the browser. + +The pipeline copies the answer onto each listing as it stores it, and from then on +`listings.currency` is the only source: comparisons happen in SQL, and everything else reads the +row. Declaring a currency therefore does not reach listings already stored. A provider that has +any needs a migration to backfill them, as `55.listing-currency.js` does for flatfox. + +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 cb68674e0..407acae51 100644 --- a/doc/providers.md +++ b/doc/providers.md @@ -231,6 +231,19 @@ 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 prices in EUR and lists its Swiss site under `currencyByHost`. Each listing stores its +currency when it is found. 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, and price ranges need a +selection with one currency. A job can only be saved with a price ceiling if its sources share a +currency. An older job that mixes them keeps filtering with its ceiling until it is next edited, +and then needs the ceiling cleared to save. 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 331b2e44d..758293b65 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 { getCurrencyForListing } 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'; @@ -607,11 +608,7 @@ 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 formattedListings = newListings.map((listing) => formatListing(listing, language)); const settings = await getSettings(); const baseUrl = settings?.baseUrl ?? ''; const sendNotifications = notify.send( @@ -627,11 +624,17 @@ class FredyPipelineExecutioner { /** * Persist new listings and pass them through. * + * The currency is settled here, once: `storeListings` is synchronous and the provider metadata is + * not, and every later step reads it off the listing or the row. + * * @param {ParsedListing[]} newListings Listings to store. - * @returns {ParsedListing[]} The same listings, unchanged. + * @returns {Promise} The same listings, now carrying their currency. */ - _save(newListings) { + async _save(newListings) { logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`); + for (const listing of newListings) { + listing.currency = await getCurrencyForListing(this._providerId, listing); + } storeListings(this._jobKey, this._providerId, newListings); return newListings; } diff --git a/lib/api/routes/dashboardRouter.js b/lib/api/routes/dashboardRouter.js index 05084c6e0..84e3849a4 100644 --- a/lib/api/routes/dashboardRouter.js +++ b/lib/api/routes/dashboardRouter.js @@ -98,6 +98,7 @@ export default async function dashboardPlugin(fastify) { numberOfListings, numberOfActiveListings, medianPriceOfListings, + currency, medianPriceSampleSize, medianPricePerSqm, } = getListingsKpisForJobIds(jobIds); @@ -131,6 +132,7 @@ export default async function dashboardPlugin(fastify) { numberOfListings, numberOfActiveListings, medianPriceOfListings, + currency, medianPriceSampleSize, medianPricePerSqm, }, diff --git a/lib/api/routes/financeRouter.js b/lib/api/routes/financeRouter.js index 07de15a71..907b23f8d 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 { FINANCE_CURRENCY } from '../../services/providers/currency.js'; import * as listingStorage from '../../services/storage/listingsStorage.js'; import { isAdmin as isAdminFn } from '../security.js'; import logger from '../../services/logger.js'; @@ -114,6 +115,9 @@ export default async function financePlugin(fastify) { const stored = getUserSettings(userId)?.finance_profile ?? null; const summary = summarizeProfile(stored); const price = Number(listing.price); + if (listing.currency !== FINANCE_CURRENCY) { + 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 }; } @@ -178,8 +182,11 @@ 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. + // scoping - a user can never score listings belonging to someone else. The currency is part + // of the query rather than a skip below: those rows would still use up `pageSize`, and euro + // listings past the cap would go unscored. const { result: listings } = listingStorage.queryListings({ + currencyFilter: FINANCE_CURRENCY, page: 1, pageSize: MAX_SCORED_LISTINGS, jobIdFilter: nullIfEmpty(filter.jobId), diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 17e1e2e2f..e9c52c104 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'; @@ -245,7 +246,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..005a253b5 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -18,6 +18,8 @@ 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 { FINANCE_CURRENCY } from '../../services/providers/currency.js'; +import { MixedCurrencyError } from '../../errors.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 +49,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 === FINANCE_CURRENCY + ? 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, @@ -653,7 +657,7 @@ export default async function listingsPlugin(fastify) { return reply.send({ deleted: changes }); } catch (error) { logger.error(error); - return reply.code(500).send({ error: error.message }); + return reply.code(error instanceof MixedCurrencyError ? 400 : 500).send({ error: error.message }); } }); diff --git a/lib/errors.js b/lib/errors.js index 7b46e369f..0d93ca081 100755 --- a/lib/errors.js +++ b/lib/errors.js @@ -15,7 +15,14 @@ class ExtendableError extends Error { } } class NoNewListingsWarning extends ExtendableError {} -export { NoNewListingsWarning }; +/** A price bound was put on listings priced in several currencies, where it has no one unit. */ +class MixedCurrencyError extends ExtendableError { + constructor() { + super('Select one currency before filtering by price.'); + } +} +export { NoNewListingsWarning, MixedCurrencyError }; export default { NoNewListingsWarning, + MixedCurrencyError, }; diff --git a/lib/mcp/jobDraftContext.js b/lib/mcp/jobDraftContext.js index b1f69b9ef..6a5b86a0a 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, currency, currencyByHost }) => ({ + id, + name, + baseUrl, + currency, + currencyByHost, + ...(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 1e2d8e07e..f31f7487a 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 { currencyForLink, jobCurrencyProblem } from '../services/providers/currency.js'; import { normalizeCommuteFilter } from '../utils/commuteBudget.js'; import { normalizeExcludedLocations } from '../utils/excludedLocations.js'; @@ -349,6 +350,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; @@ -412,7 +419,12 @@ export function describeDraft(draft, ctx) { spec == null ? '–' : [ - spec.maxPrice != null ? `max ${spec.maxPrice} EUR` : null, + spec.maxPrice != null + ? `max ${spec.maxPrice} ${currencyForLink( + ctx.providers.find((p) => p.id === draft.providers[0]?.id), + draft.providers[0]?.url, + )}` + : null, spec.minSize != null ? `min ${spec.minSize} m²` : null, spec.minRooms != null ? `min ${spec.minRooms} rooms` : null, ] @@ -488,7 +500,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 providers’ currency, minimum size in m², ' + 'minimum number of rooms, words that should exclude a listing, locations to exclude from listing addresses, ' + (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 8ce8350b2..31c9ed88e 100644 --- a/lib/mcp/mcpAdapter.js +++ b/lib/mcp/mcpAdapter.js @@ -3,6 +3,8 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ +import { FINANCE_CURRENCY } from '../services/providers/currency.js'; +import { MixedCurrencyError } from '../errors.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { nanoid } from 'nanoid'; @@ -184,6 +186,13 @@ export function createMcpServer() { jobId: z.string().optional().describe('Filter listings by job ID'), activeOnly: z.boolean().optional().describe('When true, only show active listings'), provider: z.string().optional().describe('Filter by provider name'), + currency: z + .string() + .regex(/^[A-Z]{3}$/) + .optional() + .describe( + 'Only include listings priced in this ISO 4217 code (e.g. EUR or CHF). Set this for minPrice or maxPrice when the listings span several currencies.', + ), createdAfter: z .number() .optional() @@ -246,6 +255,7 @@ export function createMcpServer() { jobId, activeOnly, provider, + currency, createdAfter, createdBefore, minPrice, @@ -267,26 +277,39 @@ export function createMcpServer() { const safePageSize = pageSize ?? 50; const mobileMask = mobileTech ? filterMask(mobileTech, mobileOperator ?? null) : null; - const result = queryListings({ - page: safePage, - pageSize: safePageSize, - freeTextFilter: filter, - jobIdFilter: jobId, - activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined, - providerFilter: provider, - createdAfter: createdAfter ?? null, - createdBefore: createdBefore ?? null, - minPrice: minPrice ?? null, - maxPrice: maxPrice ?? null, - sortField: sortField ?? null, - sortDir: sortDir ?? 'desc', - statusFilter: status, - connectivityMinDown: minDownMbit ?? null, - connectivityFiberOnly: fiberOnly === true, - connectivityMobileMask: mobileMask, - userId: user.id, - isAdmin: user.isAdmin, - }); + let result; + try { + result = queryListings({ + page: safePage, + pageSize: safePageSize, + freeTextFilter: filter, + jobIdFilter: jobId, + activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined, + providerFilter: provider, + currencyFilter: currency ?? null, + createdAfter: createdAfter ?? null, + createdBefore: createdBefore ?? null, + minPrice: minPrice ?? null, + maxPrice: maxPrice ?? null, + sortField: sortField ?? null, + sortDir: sortDir ?? 'desc', + statusFilter: status, + connectivityMinDown: minDownMbit ?? null, + connectivityFiberOnly: fiberOnly === true, + connectivityMobileMask: mobileMask, + userId: user.id, + isAdmin: user.isAdmin, + }); + } catch (error) { + if (!(error instanceof MixedCurrencyError)) throw error; + // A job can hold one provider's sites in two currencies, so jobId and provider alone may + // never get there. The error names everything the model can change. + return normalizeError( + 'minPrice and maxPrice need listings in one currency, and these span several. ' + + 'Set currency, narrow the search with jobId or provider, or leave the price range out.', + 'list_listings', + ); + } return normalizeListListings(result, { page: safePage, pageSize: safePageSize }); }, @@ -454,6 +477,9 @@ export function createMcpServer() { if (!listing) { return normalizeError('Listing not found.', 'calculate_financing'); } + if (listing.currency !== FINANCE_CURRENCY) { + 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'); } @@ -756,7 +782,12 @@ export function createMcpServer() { ), 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 dbb25bbaa..b4274d0af 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -193,7 +193,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.price != null && 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 { @@ -216,7 +216,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.price != null && 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..807a4bdb6 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', + currencyByHost: { 'betterhomes.ch': 'CHF' }, 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/application/placeholders.js b/lib/services/application/placeholders.js index ad20b9d8f..186722fee 100644 --- a/lib/services/application/placeholders.js +++ b/lib/services/application/placeholders.js @@ -202,15 +202,17 @@ function toDate(value) { /** * @param {unknown} value * @param {string} locale + * @param {string} [currency='EUR'] ISO 4217 code. A listing brings its own; the applicant's income + * has none on record, so it stays in euros like the rest of the finance profile. * @param {number} [digits=0] * @returns {string} */ -function money(value, locale, digits = 0) { +function money(value, locale, currency = 'EUR', digits = 0) { const amount = numeric(value); if (amount == null) return ''; return new Intl.NumberFormat(locale, { style: 'currency', - currency: 'EUR', + currency, minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(amount); @@ -321,13 +323,13 @@ export function resolvePlaceholderValues({ return { 'listing.title': text(source.title), 'listing.address': text(source.address), - 'listing.price': money(source.price, locale), + 'listing.price': money(source.price, locale, source.currency), // The unit is appended only once there is a number to append it to: `" m²"` is non-empty and // would survive the line-drop rule as `Wohnfläche: m²`, which is the exact failure that rule // exists to prevent. 'listing.size': size.length > 0 ? `${size} m²` : '', 'listing.rooms': decimal(source.rooms, locale), - 'listing.pricePerSqm': money(source.price_per_sqm ?? source.pricePerSqm, locale, 2), + 'listing.pricePerSqm': money(source.price_per_sqm ?? source.pricePerSqm, locale, source.currency, 2), 'listing.link': text(source.link), 'listing.provider': text(providerName) || text(source.provider), 'listing.publishedAt': day(source.published_at ?? source.publishedAt, locale), diff --git a/lib/services/listings/marketBenchmark.js b/lib/services/listings/marketBenchmark.js index e56c163a5..953f0991f 100644 --- a/lib/services/listings/marketBenchmark.js +++ b/lib/services/listings/marketBenchmark.js @@ -124,9 +124,14 @@ const KM_PER_DEGREE_LAT = 111.32; * index scan. The exact distance is measured afterwards, in {@link computeBenchmark}, where it * decides the radius rather than the order. * - * @type {string} + * The currency is deliberately not written in here. Migration 41 prepares {@link COMPARABLES_SQL} + * on a table that has no such column yet, so naming it would stop a fresh install from migrating. + * It arrives as `scope` instead - see {@link computeBenchmark}. + * + * @param {string|null} [scope] One more condition on the comparables, against the same aliases. + * @returns {string} */ -export const COMPARABLES_SQL = ` +const comparablesSql = (scope = null) => ` SELECT l.price AS price, l.size AS size, l.latitude AS latitude, @@ -134,6 +139,7 @@ export const COMPARABLES_SQL = ` FROM listings l JOIN jobs j ON j.id = l.job_id WHERE l.id != @id + ${scope == null ? '' : `AND ${scope}`} AND j.deal_type = @dealType AND l.manually_deleted = 0 AND l.price > 0 @@ -145,6 +151,9 @@ export const COMPARABLES_SQL = ` LIMIT @limit `; +/** The comparables query with no further scope. @type {string} */ +export const COMPARABLES_SQL = comparablesSql(); + /** * The price of one square metre, to the cent. * @@ -281,11 +290,14 @@ export function isLocated(listing) { * @param {{id: string, latitude: number, longitude: number}} listing The listing being measured. * @param {('rent'|'buy')} dealType Deal type of the job that found it. * @param {(sql: string, params: Object) => Array} runQuery Executor for {@link COMPARABLES_SQL}. + * @param {{condition: string, params: Object}} [scope] Narrows the comparables further: a condition + * against the query's aliases plus its bind parameters. Only migration 41, which runs before the + * column it would scope on exists, leaves this out. * @returns {{medianPricePerSqm: number, sampleSize: number, radiusKm: number}|null} * `null` when the listing has no position, the job has no deal type, or no radius reaches * {@link MIN_SAMPLE} comparable listings. */ -export function computeBenchmark(listing, dealType, runQuery) { +export function computeBenchmark(listing, dealType, runQuery, scope) { if (!isLocated(listing) || dealType == null) { return null; } @@ -295,7 +307,8 @@ export function computeBenchmark(listing, dealType, runQuery) { const widest = BENCHMARK_RADII_KM[BENCHMARK_RADII_KM.length - 1]; const box = boundingBox(lat, lng, widest); - const rows = runQuery(COMPARABLES_SQL, { + const rows = runQuery(comparablesSql(scope?.condition), { + ...scope?.params, id: listing.id, dealType, minSize: MIN_SIZE_SQM, diff --git a/lib/services/listings/priceHistoryService.js b/lib/services/listings/priceHistoryService.js index 600ce1e2c2b7de8e0d6fc5ab45d19145dec4e662..81c575be0b2713fcfc61d1f91684ab323671dcdf 100644 GIT binary patch delta 44 zcmeCM?zY~roN+P}Q~cxwjM1ADnQ}x}Vk!*NHlGu-WS;y*+HEtt)Mv)c4l*8$0As-q AM*si- delta 41 xcmeCS?y%mloN+QQQ^@26jA@$_nQ}xJ88%-Ovt-^JAoZDXvVfGsW?LCQMgS{o4M+e0 diff --git a/lib/services/providers/currency.js b/lib/services/providers/currency.js new file mode 100644 index 000000000..24e8deb8a --- /dev/null +++ b/lib/services/providers/currency.js @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { normalizeHost } from '../jobs/providerUrl.js'; + +/** The fallback for a provider without a currency declaration. */ +export const DEFAULT_CURRENCY = 'EUR'; + +/** + * The one currency the finance module models. Its rates and purchase costs are German, so a listing + * priced in anything else gets no calculation and no affordability verdict. + */ +export const FINANCE_CURRENCY = 'EUR'; + +/** + * @param {unknown} value + * @returns {boolean} Whether this is shaped like an ISO 4217 code. + */ +const isCurrencyCode = (value) => typeof value === 'string' && /^[A-Z]{3}$/.test(value); + +/** + * Which currency a link on this provider is priced in. + * + * Declared data and the link's host, nothing else: a job's search url, a listing's advert link and + * the job form's lookup of the same `currencyByHost` all have to reach the same answer. + * + * @param {{currency?: string, currencyByHost?: Record}|null|undefined} meta + * @param {string|null} [link] A listing's link or a job's search url. + * @returns {string} ISO 4217 code; EUR when absent or malformed. + */ +export function currencyForLink(meta, link) { + const byHost = meta?.currencyByHost?.[normalizeHost(link)]; + if (isCurrencyCode(byHost)) return byHost; + return isCurrencyCode(meta?.currency) ? meta.currency : DEFAULT_CURRENCY; +} + +/** + * 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) => currencyForLink(metadata.get(p.id), 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..2d84b0d87 --- /dev/null +++ b/lib/services/providers/providerCurrency.js @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { getProviders } from '../../utils.js'; +import { currencyForLink } from './currency.js'; + +/** + * The currency a listing is stored with. + * + * Asked once, when the pipeline writes the row. Everything after that reads `listings.currency`, + * because a comparison in SQL has no provider module to ask. + * + * @param {string} providerId The provider the listing was found by. + * @param {{link?: string|null}|null} listing + * @returns {Promise} ISO 4217 code. + */ +export async function getCurrencyForListing(providerId, listing) { + const meta = (await getProviders()).find((p) => p.metaInformation.id === providerId)?.metaInformation; + return currencyForLink(meta, listing?.link); +} diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index 43392b764..a6353d114 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -11,6 +11,8 @@ 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, FINANCE_CURRENCY } from '../providers/currency.js'; +import { MixedCurrencyError } from '../../errors.js'; /** * How many ids go into a single `IN (...)` list. @@ -218,10 +220,13 @@ function toDayKey(date) { * scalars. SQLite has no median(), but offsetting into an ordered result is exact and can be * served straight from an index. * + * A median over several currencies describes none of them, so one spanning them is left out: + * `currency` is null then, and the two medians with it. + * * @param {string[]} jobIds - * @returns {{ numberOfListings: number, numberOfActiveListings: number, medianPriceOfListings: number, - * medianPriceSampleSize: number, medianPricePerSqm: ({dealType: ('rent'|'buy'), value: number, - * sampleSize: number}|null) }} + * @returns {{ numberOfListings: number, numberOfActiveListings: number, + * medianPriceOfListings: (number|null), currency: (string|null), medianPriceSampleSize: number, + * medianPricePerSqm: ({dealType: ('rent'|'buy'), value: number, sampleSize: number}|null) }} */ export const getListingsKpisForJobIds = (jobIds = []) => { if (!Array.isArray(jobIds) || jobIds.length === 0) { @@ -229,6 +234,7 @@ export const getListingsKpisForJobIds = (jobIds = []) => { numberOfListings: 0, numberOfActiveListings: 0, medianPriceOfListings: 0, + currency: DEFAULT_CURRENCY, medianPriceSampleSize: 0, medianPricePerSqm: null, }; @@ -248,6 +254,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => { const totalCount = Number(counts?.totalCount ?? 0); const activeCount = Number(counts?.activeCount ?? 0); const pricedCount = Number(counts?.pricedCount ?? 0); + const currencies = SqliteConnection.query(`SELECT DISTINCT currency ${from} AND price IS NOT NULL`, jobIds); + const currency = currencies.length > 1 ? null : (currencies[0]?.currency ?? DEFAULT_CURRENCY); let medianPrice = 0; if (pricedCount > 0) { @@ -270,11 +278,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), }; }; @@ -399,7 +408,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, currency, size, rooms, price_per_sqm, image_url, created_at, job_id, provider, market_median_sqm, market_sample_size, market_radius_km FROM listings WHERE job_id IN (${placeholders}) @@ -812,7 +821,7 @@ export const PRICE_CHECK_STALE_MS = 7 * 24 * 60 * 60 * 1000; * @param {number} [params.limit=100] Maximum listings to return. * @param {number} [params.staleAfterMs=PRICE_CHECK_STALE_MS] Re-probe listings older than this. * @param {number} [params.now=Date.now()] Injectable for tests. - * @returns {Array<{id: string, link: string, provider: string, price: number|null, job_id: string, title: string|null, address: string|null, size: number|null, rooms: number|null}>} + * @returns {Array<{id: string, link: string, provider: string, price: number|null, currency: string, job_id: string, title: string|null, address: string|null, size: number|null, rooms: number|null}>} */ export const getListingsDueForPriceCheck = ({ limit = 100, @@ -821,7 +830,7 @@ export const getListingsDueForPriceCheck = ({ } = {}) => { const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 100; return SqliteConnection.query( - `SELECT id, link, provider, price, job_id, title, address, size, rooms + `SELECT id, link, provider, price, currency, job_id, title, address, size, rooms FROM listings WHERE (is_active IS NULL OR is_active = 1) AND manually_deleted = 0 @@ -934,13 +943,13 @@ export const getPriceHistory = (listingId, { limit = 200 } = {}) => { * @param {string} providerId * @param {string[]} links * @returns {Array<{id: string, hash: string, link: string, provider: string, price: number|null, - * job_id: string, title: string, address: string|null, size: number|null, rooms: number|null, - * manually_deleted: number}>} + * currency: string, job_id: string, title: string, address: string|null, size: number|null, + * rooms: number|null, manually_deleted: number}>} */ export const getListingsByLinkForJobAndProvider = (jobId, providerId, links) => { if (!Array.isArray(links) || links.length === 0) return []; return SqliteConnection.query( - `SELECT id, hash, link, provider, price, job_id, title, address, size, rooms, manually_deleted + `SELECT id, hash, link, provider, price, currency, job_id, title, address, size, rooms, manually_deleted FROM listings WHERE job_id = @jobId AND provider = @providerId @@ -1008,6 +1017,7 @@ export const setListingHash = (listingId, hash) => { * id: string, // unique id * hash: string // stable hash/id of the listing (used as unique hash) * price?: string, // e.g., "1.234 €" or "1,234€" + * currency: string, // ISO 4217. Required: the column refuses a row that does not say * size?: string, // e.g., "70 m²" * title?: string, * image?: string, // image URL @@ -1039,12 +1049,12 @@ export const storeListings = (jobId, providerId, listings) => { // `RETURNING id` now answers for the conflicting row as well and the lookup below is left as // the fallback for the case where nothing came back at all. const stmt = db.prepare( - `INSERT INTO listings (id, hash, provider, job_id, price, charges_included, charges, size, rooms, build_year, - energy_class, title, image_url, description, address, link, created_at, is_active, - latitude, longitude, published_at, price_per_sqm, added_manually) - VALUES (@id, @hash, @provider, @job_id, @price, @charges_included, @charges, @size, @rooms, @build_year, - @energy_class, @title, @image_url, @description, @address, @link, @created_at, 1, @latitude, - @longitude, @published_at, @price_per_sqm, @added_manually) + `INSERT INTO listings (id, hash, provider, job_id, price, currency, charges_included, charges, size, rooms, + build_year, energy_class, title, image_url, description, address, link, created_at, + is_active, latitude, longitude, published_at, price_per_sqm, added_manually) + VALUES (@id, @hash, @provider, @job_id, @price, @currency, @charges_included, @charges, @size, @rooms, + @build_year, @energy_class, @title, @image_url, @description, @address, @link, @created_at, 1, + @latitude, @longitude, @published_at, @price_per_sqm, @added_manually) ON CONFLICT(job_id, hash) DO UPDATE SET published_at = COALESCE(@published_at, listings.published_at) RETURNING id`, ); @@ -1057,6 +1067,9 @@ export const storeListings = (jobId, providerId, listings) => { provider: providerId, job_id: jobId, price: item.price, + // Not defaulted here. A caller that forgot it fails on the NOT NULL column, which is the + // better outcome: the other one is a franc price stored as euros, with nothing to show for it. + currency: item.currency, // Whether the rent has the charges in it: 1, 0, or NULL where the portal did not say. charges_included: item.chargesIncluded == null ? null : item.chargesIncluded ? 1 : 0, charges: Number.isFinite(item.charges) ? item.charges : null, @@ -1128,7 +1141,7 @@ export const storeListings = (jobId, providerId, listings) => { * everything the sample is too thin for. * * @param {string} jobId Job the listings belong to. - * @param {Array<{id: string, latitude?: number|null, longitude?: number|null}>} listings + * @param {Array<{id: string, currency: string, latitude?: number|null, longitude?: number|null}>} listings * @returns {void} */ export const applyMarketBenchmark = (jobId, listings) => { @@ -1143,7 +1156,11 @@ export const applyMarketBenchmark = (jobId, listings) => { const runQuery = (sql, params) => SqliteConnection.query(sql, params); for (const listing of listings) { - const benchmark = computeBenchmark(listing, dealType, runQuery); + // Near a border the box reaches into the next currency, and francs in a euro median move it. + const benchmark = computeBenchmark(listing, dealType, runQuery, { + condition: 'l.currency = @currency', + params: { currency: listing.currency }, + }); if (benchmark == null) { continue; } @@ -1242,6 +1259,7 @@ function buildListingWhere({ jobNameFilter, jobIdFilter, providerFilter, + currencyFilter = null, watchListFilter, statusFilter, freeTextFilter, @@ -1294,6 +1312,11 @@ function buildListingWhere({ params.providerName = String(providerFilter).trim(); whereParts.push('(l.provider = @providerName)'); } + // currencyFilter: only listings priced in this ISO 4217 code + if (currencyFilter != null) { + params.currencyFilter = currencyFilter; + whereParts.push('(l.currency = @currencyFilter)'); + } // watchListFilter: when true -> only watched listings, false -> only unwatched if (watchListFilter === true) { whereParts.push('(wl.id IS NOT NULL)'); @@ -1322,11 +1345,11 @@ function buildListingWhere({ whereParts.push('(l.created_at <= @createdBefore)'); } // Price range filters - if (Number.isFinite(minPrice) && minPrice >= 0) { + if (isPriceBound(minPrice)) { params.minPrice = minPrice; whereParts.push('(l.price >= @minPrice)'); } - if (Number.isFinite(maxPrice) && maxPrice >= 0) { + if (isPriceBound(maxPrice)) { params.maxPrice = maxPrice; whereParts.push('(l.price <= @maxPrice)'); } @@ -1344,6 +1367,8 @@ 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) { + params.affordabilityCurrency = FINANCE_CURRENCY; + whereParts.push('(l.currency = @affordabilityCurrency)'); const arms = []; for (const dealType of [DEAL_TYPES.BUY, DEAL_TYPES.RENT]) { const window = affordabilityBand[dealType]; @@ -1469,6 +1494,47 @@ const LISTING_FILTER_FROM_SQL = `FROM listings l LEFT 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`; +/** + * Whether a price filter value actually bounds anything. + * + * @param {unknown} value + * @returns {boolean} + */ +const isPriceBound = (value) => Number.isFinite(value) && value >= 0; + +/** + * Refuse a price bound that would have to hold in two currencies at once. + * + * Asked of the selection without its bounds: with them applied, a limit of 2.000 would quietly keep + * the francs under it next to the euros under it, and the answer would look like a filtered list. + * A listing with no price is no part of that, since a bound drops it either way. + * + * Kept out of {@link buildListingWhere}, which builds text and reads nothing, and called from both + * of its callers so they keep agreeing on what a filter matches. Which answer this becomes is the + * caller's to decide: a status code for a route, a hint for a model. + * + * @param {Object} filters - The same filter fields {@link queryListings} takes. + * @returns {void} + * @throws {MixedCurrencyError} When the selection is priced in more than one currency. + */ +function assertPriceBoundHasOneCurrency(filters) { + if (!isPriceBound(filters.minPrice) && !isPriceBound(filters.maxPrice)) { + return; + } + const { whereSql, params } = buildListingWhere({ ...filters, minPrice: null, maxPrice: null }); + const currencies = SqliteConnection.query( + `SELECT l.currency + ${LISTING_FILTER_FROM_SQL} + ${whereSql} + GROUP BY l.currency + HAVING COUNT(l.price) > 0`, + params, + ); + if (currencies.length > 1) { + throw new MixedCurrencyError(); + } +} + /** * Query listings with pagination, filtering and sorting. * @@ -1479,6 +1545,7 @@ const LISTING_FILTER_FROM_SQL = `FROM listings l * @param {object} [params.activityFilter] * @param {object} [params.jobNameFilter] * @param {object} [params.providerFilter] + * @param {string|null} [params.currencyFilter=null] - Only listings priced in this ISO 4217 code. * @param {object} [params.watchListFilter] * @param {('applied'|'rejected'|'accepted'|'none')} [params.statusFilter] - Filter by listing status. 'none' matches NULL. * @param {string|null} [params.sortField=null] - One of: 'created_at','published_at','price','size','provider','title'. @@ -1504,6 +1571,7 @@ const LISTING_FILTER_FROM_SQL = `FROM listings l * 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. * @returns {{ totalNumber:number, page:number, result:Object[] }} + * @throws {MixedCurrencyError} When a price bound spans several currencies. */ export const queryListings = ({ pageSize = 50, @@ -1512,6 +1580,7 @@ export const queryListings = ({ jobNameFilter, jobIdFilter, providerFilter, + currencyFilter = null, watchListFilter, statusFilter, freeTextFilter, @@ -1535,11 +1604,12 @@ export const queryListings = ({ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; const offset = (safePage - 1) * safePageSize; - const { whereSql: whereSqlWithAlias, params: filterParams } = buildListingWhere({ + const filters = { activityFilter, jobNameFilter, jobIdFilter, providerFilter, + currencyFilter, watchListFilter, statusFilter, freeTextFilter, @@ -1555,7 +1625,9 @@ export const queryListings = ({ userId, isAdmin, hiddenOnly, - }); + }; + assertPriceBoundHasOneCurrency(filters); + const { whereSql: whereSqlWithAlias, params: filterParams } = buildListingWhere(filters); const params = { ...filterParams, limit: safePageSize, offset }; // whitelist sortable fields to avoid SQL injection; map to fully-qualified expressions @@ -1605,7 +1677,11 @@ export const queryListings = ({ params, ); - return { totalNumber, page: safePage, result: attachTravelTimes(rows.map(parseListingStatus)) }; + return { + totalNumber, + page: safePage, + result: attachTravelTimes(rows.map(parseListingStatus)), + }; }; /** @@ -1744,8 +1820,12 @@ export const deleteListingsById = (ids, hardDelete = false) => { * and `isAdmin`. `hiddenOnly` decides whether this acts on the visible rows or the hidden ones. * @param {boolean} [hardDelete=false] - Remove the rows outright instead of marking them hidden. * @returns {{changes: number}} How many rows were deleted. + * @throws {MixedCurrencyError} When a price bound spans several currencies. */ export const deleteListingsByFilter = (filters = {}, hardDelete = false) => { + // shortcut: the bulk delete route carries no price bound yet, so this cannot fire from there. It + // stays so a bound added to the shared filters later cannot delete across currencies. + assertPriceBoundHasOneCurrency(filters); const { whereSql, params } = buildListingWhere(filters); const idSubselect = `SELECT l.id ${LISTING_FILTER_FROM_SQL} @@ -1948,6 +2028,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {} `SELECT l.id, l.title, l.price, + l.currency, l.size, l.rooms, l.address, diff --git a/lib/services/storage/migrations/sql/55.listing-currency.js b/lib/services/storage/migrations/sql/55.listing-currency.js new file mode 100644 index 000000000..d7eb45edb --- /dev/null +++ b/lib/services/storage/migrations/sql/55.listing-currency.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { BENCHMARK_RADII_KM, boundingBox, computeBenchmark } from '../../../listings/marketBenchmark.js'; +import { detectScamSignals } from '../../../listings/scamSignals.js'; + +/** + * Measure again the listings whose stored benchmark may have mixed two currencies. + * + * Until now the comparables were picked by distance alone, so near a border a median could blend + * francs and euros, and a listing could be flagged as far below a market that was never its own. + * + * Only those listings: a benchmark states what the area looked like when Fredy found the listing, + * and measuring the others again would swap that for today's market. A listing is a candidate when + * another currency sits anywhere in its widest search box, which is the same box the comparables + * came from. The price signal hangs off the median, so it is read again with it. + * + * @param {import('better-sqlite3').Database} db + * @returns {void} + */ +function remeasureAcrossCurrencies(db) { + const measured = db + .prepare( + `SELECT l.id, l.title, l.description, l.currency, l.latitude, l.longitude, l.price_per_sqm, + j.deal_type AS dealType + FROM listings l + JOIN jobs j ON j.id = l.job_id + WHERE l.market_median_sqm IS NOT NULL`, + ) + .all(); + + const otherCurrencyNearby = db.prepare( + `SELECT 1 + FROM listings + WHERE currency != @currency + AND latitude BETWEEN @minLat AND @maxLat + AND longitude BETWEEN @minLng AND @maxLng + LIMIT 1`, + ); + const write = db.prepare( + `UPDATE listings + SET market_median_sqm = @median, + market_sample_size = @sampleSize, + market_radius_km = @radiusKm, + scam_signals = @signals + WHERE id = @id`, + ); + + let comparables = null; + const runQuery = (sql, params) => (comparables ??= db.prepare(sql)).all(params); + const widest = BENCHMARK_RADII_KM[BENCHMARK_RADII_KM.length - 1]; + + for (const listing of measured) { + const box = boundingBox(listing.latitude, listing.longitude, widest); + if (otherCurrencyNearby.get({ ...box, currency: listing.currency }) == null) { + continue; + } + const benchmark = computeBenchmark(listing, listing.dealType, runQuery, { + condition: 'l.currency = @currency', + params: { currency: listing.currency }, + }); + const median = benchmark?.medianPricePerSqm ?? null; + const signals = detectScamSignals({ ...listing, market_median_sqm: median }); + write.run({ + id: listing.id, + median, + sampleSize: benchmark?.sampleSize ?? null, + radiusKm: benchmark?.radiusKm ?? null, + signals: signals.length === 0 ? null : JSON.stringify(signals), + }); + } +} + +/** + * Keep the currency a listing is priced in on the listing. + * + * Benchmarks, the affordability filter and the dashboard medians compare prices in SQL, and SQL has + * no provider module to ask. Resolving the currency at read time meant generating a clause per host + * of every provider; a column turns each of those checks into `l.currency = ?`. + * + * Only two sources of the listings stored so far price in anything but euros: flatfox, and the + * Swiss site of BETTERHOMES. Written out here rather than read from the provider modules, because a + * migration has to keep meaning what it meant when it ran. + * + * An instance holding one currency has nothing to measure again, which is nearly all of them. + * + * @param {import('better-sqlite3').Database} db + * @returns {void} + */ +export function up(db) { + const columns = db.prepare(`PRAGMA table_info(listings)`).all(); + if (!columns.some((column) => column.name === 'currency')) { + db.exec(`ALTER TABLE listings ADD COLUMN currency TEXT NOT NULL DEFAULT 'EUR'`); + } + db.exec( + `UPDATE listings + SET currency = 'CHF' + WHERE provider = 'flatfox' + OR (provider = 'betterhomes' + AND (link LIKE 'https://www.betterhomes.ch/%' OR link LIKE 'https://betterhomes.ch/%'))`, + ); + + const { currencies } = db.prepare(`SELECT COUNT(DISTINCT currency) AS currencies FROM listings`).get(); + if (currencies > 1) { + remeasureAcrossCurrencies(db); + } +} diff --git a/lib/services/tour/tourService.js b/lib/services/tour/tourService.js index b3a8a1136..0c9b5e270 100644 --- a/lib/services/tour/tourService.js +++ b/lib/services/tour/tourService.js @@ -16,6 +16,7 @@ import { normalizeGerman } from '../connectivity/normalize.js'; import { packMobile } from '../connectivity/mobileBits.js'; import SqliteConnection from '../storage/SqliteConnection.js'; import { DEAL_TYPES } from '../dealType.js'; +import { DEFAULT_CURRENCY } from '../providers/currency.js'; import logger from '../logger.js'; import demoProviders from '../demo/demoProviders.json' with { type: 'json' }; import { TOUR_CONNECTIVITY, TOUR_JOB_NAME, TOUR_LISTINGS, TOUR_PROVIDER_LINKS } from './tourData.js'; @@ -223,6 +224,7 @@ function seedTourData(userId, providers, now) { description: listing.description, address: listing.address, price: listing.price, + currency: DEFAULT_CURRENCY, size: listing.size, rooms: listing.rooms, latitude: listing.latitude, diff --git a/lib/services/tracking/Tracker.js b/lib/services/tracking/Tracker.js index def9036de..710b99f4a 100644 --- a/lib/services/tracking/Tracker.js +++ b/lib/services/tracking/Tracker.js @@ -6,6 +6,7 @@ import { getJobs } from '../storage/jobStorage.js'; import { getListingsKpisForJobIds } from '../storage/listingsStorage.js'; import { normalizeDealType } from '../dealType.js'; +import { DEFAULT_CURRENCY } from '../providers/currency.js'; import { resolveJobPriceRange } from './priceRange.js'; import { getUniqueId } from './uniqueId.js'; import { getPackageVersion, getProviders, inDevMode } from '../../utils.js'; @@ -135,9 +136,12 @@ export const trackJobPriceObservation = async (job, providers = []) => { // is computed per deal type - which makes this the earlier of two exits, not the only one. if (jobType == null) return; - const { medianPriceOfListings, medianPricePerSqm } = getListingsKpisForJobIds([job.id]); + const { medianPriceOfListings, medianPricePerSqm, currency } = getListingsKpisForJobIds([job.id]); // Nothing measurable yet: no listing of this job carries a price per square metre. if (medianPricePerSqm == null) return; + // The payload has no unit, and what receives it reads a German market in euros. A figure in + // francs would sort into a band as if it were one. + if (currency !== DEFAULT_CURRENCY) return; const { min, max } = resolveJobPriceRange(job, providers); const payload = { diff --git a/lib/types/listing.js b/lib/types/listing.js index 5652ae90b..6b5008bac 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 code of `price`. Absent on a freshly normalized listing: the pipeline settles it from the provider's metadata when it stores the row, and from there on `listings.currency` is the answer. * @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/types/providerConfig.js b/lib/types/providerConfig.js index d8787bd8f..3f473f28c 100644 --- a/lib/types/providerConfig.js +++ b/lib/types/providerConfig.js @@ -97,6 +97,8 @@ * @property {string} baseUrl The portal's root, used to build absolute links out of relative ones. * @property {string[]} countries ISO 3166-1 alpha-2 codes the provider serves, lowercase. Required - see `lib/services/providers/countries.js`. Read by the geocoder, which searches Nominatim within them, by the map, whose `maxBounds` is the union of their bounding boxes, and by the job form, which flags each provider with its country. * @property {(listing: any) => (string|null)} [countryOf] Which single country *this* listing is in, for a provider that serves several and can tell them apart. Optional: a provider covering one country has nothing to narrow, and one that cannot tell from a stored row should not guess. Returns one of the codes `countries` declares, or null to leave the answer at all of them; a code outside the declaration is discarded, because `countries` is what the job form and the map are built from and a narrowing may not widen. Read wherever a country is resolved for one listing rather than for the provider - the pipeline's geocode step, the geocoding sweep, the listing detail's "look this address up again", and the connectivity sweep, which picks a country's coverage register by it. idealista is the one shipped implementation: three national sites, one provider, and the advert's own link says which. + * @property {string} [currency] ISO 4217 code the provider prices in. Absent means `EUR`, which is why no euro portal declares it. Copied onto each listing as it is stored, so changing it does not reach listings already stored. + * @property {Record} [currencyByHost] The exceptions to `currency`, for a provider whose national sites price differently, keyed by a bare host from `hosts`. Data rather than a function because three places have to reach the same answer: the listing's link when it is stored, a job's search url for the price ceiling, and the job form in the browser, which only has this JSON. */ export {}; diff --git a/lib/utils/formatListing.js b/lib/utils/formatListing.js index f2c80ed38..b9e77af74 100644 --- a/lib/utils/formatListing.js +++ b/lib/utils/formatListing.js @@ -43,34 +43,24 @@ 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`. * @returns {FormattedListing} A copy of the listing with formatted strings for price, size, and rooms. */ -export const formatListing = (listing, language = 'en', currency = 'EUR') => { +export const formatListing = (listing, language = 'en') => { const rooms = ROOMS_LABEL[language] ?? ROOMS_LABEL.en; return { ...listing, - price: listing.price != null ? formatPrice(listing.price, currency) : null, + price: listing.price != null ? formatPrice(listing.price, listing.currency) : null, size: listing.size != null ? `${listing.size} m²` : null, rooms: listing.rooms != null ? `${listing.rooms} ${rooms}` : null, // Null unless a router actually answered for this listing. Adapters drop it with the same @@ -128,10 +118,11 @@ const PRICE_CHANGE_LABEL = { export const formatPriceChange = (change, language = 'en') => { const labels = PRICE_CHANGE_LABEL[language] ?? PRICE_CHANGE_LABEL.en; const percent = Math.abs(change.changePercent).toFixed(1); + const { currency } = change.listing; return { ...formatListing(change.listing, language), - oldPrice: formatPrice(change.oldPrice), - newPrice: formatPrice(change.newPrice), + 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..45e2ac1db 100644 --- a/test/api/dashboardLatest.test.js +++ b/test/api/dashboardLatest.test.js @@ -46,6 +46,7 @@ describe('the dashboard listing reads', () => { title TEXT, address TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', size REAL, rooms REAL, price_per_sqm REAL, @@ -159,6 +160,7 @@ describe('the dashboard listing reads', () => { [ 'address', 'created_at', + 'currency', 'id', 'image_url', 'job_id', diff --git a/test/api/financeRouter.test.js b/test/api/financeRouter.test.js index e0974f67f..cad750096 100644 --- a/test/api/financeRouter.test.js +++ b/test/api/financeRouter.test.js @@ -44,6 +44,7 @@ const PROFILE = { const listing = (id, price, dealType = 'buy') => ({ id, price, + currency: 'EUR', dealType, title: `Listing ${id}`, provider: 'immoscout', @@ -402,6 +403,22 @@ describe('GET /listing/:listingId', () => { expect(getListingById).toHaveBeenCalledWith('someone-elses', 'user-1', false); }); + it('does not run EUR financing against a PLN listing', async () => { + getListingById.mockReturnValue({ ...listing('pl', 1050000), currency: 'PLN' }); + const app = await buildApp(); + const detail = (await app.inject({ method: 'GET', url: '/listing/pl' })).json(); + expect(detail).toMatchObject({ result: null, scored: null, unsupportedCurrency: true }); + await app.close(); + }); + + // Filtered afterwards they would still use up the cap, and euro listings past it go unscored. + it('keeps other currencies out of the affordability sweep in the query itself', async () => { + const app = await buildApp(); + await app.inject({ method: 'POST', url: '/affordability', payload: { profile: PROFILE } }); + expect(queryListings.mock.calls.at(-1)[0]).toMatchObject({ currencyFilter: 'EUR' }); + 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..f62e1d2b6 100644 --- a/test/api/listingsAffordabilityFilter.test.js +++ b/test/api/listingsAffordabilityFilter.test.js @@ -74,6 +74,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' }); @@ -285,6 +296,7 @@ describe('verdicts and the rent basis', () => { const rentRow = (id, chargesIncluded, extra = {}) => ({ id, price: 1100, + currency: 'EUR', dealType: 'rent', charges_included: chargesIncluded, ...extra, diff --git a/test/api/listingsFilteredDelete.test.js b/test/api/listingsFilteredDelete.test.js index 50f39bdc8..ec77d0ee7 100644 --- a/test/api/listingsFilteredDelete.test.js +++ b/test/api/listingsFilteredDelete.test.js @@ -38,6 +38,7 @@ import { deleteListingsByFilter } from '../../lib/services/storage/listingsStora import { getSettings, getUserSettings } from '../../lib/services/storage/settingsStorage.js'; import { isAdmin } from '../../lib/api/security.js'; import listingsPlugin from '../../lib/api/routes/listingsRouter.js'; +import { MixedCurrencyError } from '../../lib/errors.js'; import { filterMask } from '../../lib/services/connectivity/mobileBits.js'; async function buildApp() { @@ -86,6 +87,20 @@ describe('DELETE /filtered', () => { expect(deleteListingsByFilter).not.toHaveBeenCalled(); }); + it('answers a price bound across currencies with a 400, not a 500', async () => { + // The storage layer only reports the refusal. Without this the catch-all would turn it into a + // 500, and a client could not tell a refused request from a broken server. + const refusal = new MixedCurrencyError(); + deleteListingsByFilter.mockImplementation(() => { + throw refusal; + }); + + const response = await del({}); + + 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..360fb3770 --- /dev/null +++ b/test/api/mcpCurrency.test.js @@ -0,0 +1,25 @@ +/* + * 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'); + }); + + // A currency with no amount reads as a broken figure, and every row has a currency now. + it('leaves the currency off a listing with no price', () => { + const listing = { id: 'pl', price: null, currency: 'CHF', title: 'Haus', provider: 'polish' }; + expect(normalizeGetListing(listing).content[0].text).toContain('- **Price:** –\n'); + const list = normalizeListListings({ totalNumber: 1, result: [listing] }, { page: 1, pageSize: 50 }); + expect(list.content[0].text).not.toContain('– CHF'); + }); +}); diff --git a/test/api/mcpRentAffordability.test.js b/test/api/mcpRentAffordability.test.js index 5f6ad462c..09cfe1f3e 100644 --- a/test/api/mcpRentAffordability.test.js +++ b/test/api/mcpRentAffordability.test.js @@ -24,11 +24,12 @@ vi.mock('../../lib/mcp/mcpAuthentication.js', () => ({ checkJobAccess: vi.fn(() => true), })); -import { getListingById } from '../../lib/services/storage/listingsStorage.js'; +import { getListingById, queryListings } from '../../lib/services/storage/listingsStorage.js'; import { getUserSettings } from '../../lib/services/storage/settingsStorage.js'; import { createMcpServer } from '../../lib/mcp/mcpAdapter.js'; import { normalizeRentAffordability } from '../../lib/mcp/mcpNormalizer.js'; import { computeBudget, scoreRentListing } from '../../lib/services/finance/affordability.js'; +import { MixedCurrencyError } from '../../lib/errors.js'; /** * 4000 net and 1400 living costs: 1400 warm is the 35 % ceiling, 1120 cold at the 25 % surcharge. A @@ -92,7 +93,14 @@ describe('calculate_financing for a rental', () => { }); it('judges a rent quoted with the charges on that rent, without a surcharge on top', async () => { - getListingById.mockReturnValue({ id: 'cc', title: 'Studio', price: 1300, dealType: 'rent', charges_included: 1 }); + getListingById.mockReturnValue({ + id: 'cc', + title: 'Studio', + price: 1300, + currency: 'EUR', + dealType: 'rent', + charges_included: 1, + }); const md = textOf(await callTool('calculate_financing', { listingId: 'cc' })); @@ -100,8 +108,22 @@ 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', currency: 'CHF', 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 }); + getListingById.mockReturnValue({ + id: 'hc', + title: 'Altbau', + price: 1300, + currency: 'EUR', + dealType: 'rent', + charges_included: 0, + }); const md = textOf(await callTool('calculate_financing', { listingId: 'hc' })); @@ -109,3 +131,26 @@ describe('calculate_financing for a rental', () => { expect(md).toContain('Warm rent **1.625 EUR**'); }); }); + +describe('list_listings across currencies', () => { + // The tool has no currency parameter, so "select one currency" is nothing a model can act on. + it('answers a price range it cannot apply with what to narrow by, not a thrown error', async () => { + queryListings.mockImplementationOnce(() => { + throw new MixedCurrencyError(); + }); + + const result = await callTool('list_listings', { minPrice: 1000 }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('**Tool:** list_listings'); + expect(textOf(result)).toContain('Set currency'); + expect(textOf(result)).toContain('jobId or provider'); + }); + + // One job can hold betterhomes.de and betterhomes.ch, which jobId and provider cannot tell apart. + it('takes a currency, so a job spanning two can still be asked about a price range', async () => { + await callTool('list_listings', { jobId: 'job-1', provider: 'betterhomes', maxPrice: 2000, currency: 'CHF' }); + + expect(queryListings.mock.calls.at(-1)[0]).toMatchObject({ currencyFilter: 'CHF', maxPrice: 2000 }); + }); +}); diff --git a/test/pipeline_ingest.test.js b/test/pipeline_ingest.test.js index ad1ebc84d..e109cf303 100644 --- a/test/pipeline_ingest.test.js +++ b/test/pipeline_ingest.test.js @@ -96,6 +96,31 @@ const pipeline = () => new Fredy({ priceTracking: { selector: '.price' } }, JOB, 'kleinanzeigen', similarity, undefined); const rows = () => db.prepare(`SELECT hash, price, added_manually, manually_deleted, job_id FROM listings`).all(); +describe('the currency of a stored listing', () => { + const ingestAs = (providerId, link) => + new Fredy({}, JOB, providerId, similarity, undefined).ingest([advert('hash-1', 1440, { link })]); + const storedCurrency = () => db.prepare(`SELECT currency FROM listings`).get().currency; + + it('comes from the provider that found it', async () => { + const [stored] = await ingestAs('flatfox', 'https://flatfox.ch/en/flat/1/'); + + expect(stored.currency).toBe('CHF'); + expect(storedCurrency()).toBe('CHF'); + }); + + it('follows the site, for a provider that prices differently per country', async () => { + await ingestAs('betterhomes', 'https://www.betterhomes.ch/de/immobilie-suchen/detail/objectId/1'); + + expect(storedCurrency()).toBe('CHF'); + }); + + it('is the euro for everyone else', async () => { + await ingestAs('kleinanzeigen', LINK); + + expect(storedCurrency()).toBe('EUR'); + }); +}); + describe('ingest', () => { it('stores the listing under the job, marked, unfiltered and unannounced', async () => { const [stored] = await pipeline().ingest([advert('hash-800', 800, { addedManually: true })]); diff --git a/test/pipeline_priceChange.test.js b/test/pipeline_priceChange.test.js index 42e1391f1..4544bbf6c 100644 --- a/test/pipeline_priceChange.test.js +++ b/test/pipeline_priceChange.test.js @@ -77,6 +77,7 @@ afterEach(() => db.close()); const advert = (hash, price, overrides = {}) => ({ id: hash, price, + currency: 'EUR', size: 60, rooms: 2, title: 'Flat', @@ -133,6 +134,17 @@ describe('a stored advert at a new price', () => { expect(change.direction).toBe('down'); }); + it('announces the change in the currency stored on the row', async () => { + storage.storeListings(JOB.id, 'kleinanzeigen', [advert('hash-800', 800, { currency: 'CHF' })]); + + await expect(pipeline()._splitOffPriceChanges([advert('hash-750', 750)])).rejects.toBeInstanceOf( + NoNewListingsWarning, + ); + + const [, [change]] = notify.sendPriceChange.mock.calls[0]; + expect(change).toMatchObject({ oldPrice: '800 CHF', newPrice: '750 CHF' }); + }); + it('stays silent when the price tracker already reported it', async () => { storage.storeListings(JOB.id, 'kleinanzeigen', [advert('hash-800', 800)]); const [{ id }] = db.prepare(`SELECT id FROM listings`).all(); diff --git a/test/provider/providerMetaInformation.test.js b/test/provider/providerMetaInformation.test.js index 95b481177..e3b2d8632 100644 --- a/test/provider/providerMetaInformation.test.js +++ b/test/provider/providerMetaInformation.test.js @@ -77,6 +77,16 @@ 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}$/); + for (const [host, currency] of Object.entries(meta.currencyByHost ?? {})) { + expect(currency, `${meta.id}.currencyByHost['${host}']`).toMatch(/^[A-Z]{3}$/); + expect(meta.hosts, `${meta.id}.currencyByHost names '${host}', which ${meta.id}.hosts lacks`).toContain(host); + } + } + }); + it('declares nothing the resolver would have to discard', () => { for (const provider of providers) { const { id, countries } = provider.metaInformation; diff --git a/test/services/application/placeholders.test.js b/test/services/application/placeholders.test.js index 3e8f761a1..cce7b7bb7 100644 --- a/test/services/application/placeholders.test.js +++ b/test/services/application/placeholders.test.js @@ -82,6 +82,13 @@ describe('listing values', () => { expect(resolve({ language: 'en' })['listing.price']).toBe('€1,200'); }); + it("prices the listing in its own currency, and leaves the applicant's income alone", () => { + const values = resolve({ listing: { ...listing, currency: 'CHF' } }); + expect(values['listing.price']).toBe('1.200\u00a0CHF'); + expect(values['listing.pricePerSqm']).toBe('15,90\u00a0CHF'); + expect(values['applicant.netIncome']).toBe('3.800\u00a0€'); + }); + it('formats the living space with its unit', () => { expect(resolve()['listing.size']).toBe('75,5 m²'); expect(resolve({ language: 'en' })['listing.size']).toBe('75.5 m²'); 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/listings/marketBenchmark.test.js b/test/services/listings/marketBenchmark.test.js index 99904727f..0a6b40683 100644 --- a/test/services/listings/marketBenchmark.test.js +++ b/test/services/listings/marketBenchmark.test.js @@ -7,6 +7,7 @@ import { describe, it, expect, vi } from 'vitest'; import { BENCHMARK_RADII_KM, + COMPARABLES_SQL, MARKET_BAND_PCT, MAX_COMPARABLES, MIN_SAMPLE, @@ -149,6 +150,21 @@ describe('located', () => { describe('benchmark for one listing', () => { const listing = { id: 'target', latitude: LAT, longitude: LNG }; + it('adds a scope to the query, with its parameters', () => { + const runQuery = vi.fn(() => []); + + computeBenchmark(listing, 'rent', runQuery, { condition: 'l.currency = @currency', params: { currency: 'CHF' } }); + + const [sql, params] = runQuery.mock.calls[0]; + expect(sql).toContain('AND l.currency = @currency'); + expect(params).toMatchObject({ currency: 'CHF', id: 'target', dealType: 'rent' }); + }); + + // Migration 41 prepares this on a table with no such column, and a fresh install runs it. + it('leaves the currency out of the query migration 41 prepares', () => { + expect(COMPARABLES_SQL).not.toContain('currency'); + }); + it('takes the median of everything inside the tightest radius that has enough of it', () => { const rows = Array.from({ length: MIN_SAMPLE }, () => comparable(1, 500)); const benchmark = computeBenchmark(listing, 'rent', () => rows); diff --git a/test/services/providerCurrency.test.js b/test/services/providerCurrency.test.js new file mode 100644 index 000000000..574f3646f --- /dev/null +++ b/test/services/providerCurrency.test.js @@ -0,0 +1,83 @@ +/* + * 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 { currencyForLink, jobCurrencyProblem } from '../../lib/services/providers/currency.js'; +import { 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', () => { + expect(currencyForLink({ countries: ['pl'], currency: 'PLN' })).toBe('PLN'); + expect(currencyForLink(null)).toBe('EUR'); + expect(currencyForLink({ countries: ['ch'] })).toBe('EUR'); + expect(currencyForLink({ currency: 'CHF' })).toBe('CHF'); + }); + + it('lets a host override the provider, with or without its www', () => { + const meta = { currency: 'EUR', currencyByHost: { 'example.ch': 'CHF' } }; + expect(currencyForLink(meta, 'https://example.ch/advert/1')).toBe('CHF'); + expect(currencyForLink(meta, 'https://www.example.ch/advert/1')).toBe('CHF'); + expect(currencyForLink(meta, 'https://www.example.de/advert/1')).toBe('EUR'); + expect(currencyForLink(meta, null)).toBe('EUR'); + expect(currencyForLink(meta, 'not a url')).toBe('EUR'); + }); + + it('defaults malformed declarations to EUR', () => { + for (const currency of [null, '', 'pln', 'PL', 1]) { + expect(currencyForLink({ currency })).toBe('EUR'); + expect(currencyForLink({ currencyByHost: { 'example.ch': currency } }, 'https://example.ch/')).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', + ); + expect(await getCurrencyForListing('betterhomes', { link: 'https://www.betterhomes.at/de/immobilien/' })).toBe( + 'EUR', + ); + }); + + it('formats discovery and every price-change amount in PLN', () => { + expect(formatListing({ price: 1050000, currency: 'PLN' }, 'en').price).toBe('1.050.000 PLN'); + const result = formatPriceChange( + { + listing: { price: 900000, currency: 'PLN' }, + oldPrice: 1000000, + newPrice: 900000, + changePercent: -10, + direction: 'down', + }, + 'en', + ); + 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(); + }); + + it('reads a job source by its url, since one provider can span two currencies', () => { + const metadata = new Map([['portal', { currency: 'EUR', currencyByHost: { 'portal.ch': 'CHF' } }]]); + const swiss = { id: 'portal', url: 'https://www.portal.ch/search' }; + const german = { id: 'portal', url: 'https://www.portal.de/search' }; + expect(jobCurrencyProblem([swiss, german], metadata, { maxPrice: 1000 })).toMatch(/same currency/); + expect(jobCurrencyProblem([swiss], metadata, { maxPrice: 1000 })).toBeNull(); + }); +}); diff --git a/test/services/storage/listingsStorage.marketBenchmark.test.js b/test/services/storage/listingsStorage.marketBenchmark.test.js index 7fc584e8a..faf51ab21 100644 --- a/test/services/storage/listingsStorage.marketBenchmark.test.js +++ b/test/services/storage/listingsStorage.marketBenchmark.test.js @@ -34,7 +34,10 @@ 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, + currency TEXT NOT NULL DEFAULT 'EUR', size INTEGER, latitude REAL, longitude REAL, @@ -74,24 +77,28 @@ describe('market benchmark against the listings table', () => { * @param {number} [options.size=50] * @param {number} [options.km=1] Kilometres due north of the cathedral. * @param {number} [options.deleted=0] + * @param {string} [options.currency='EUR'] */ - const addListing = (id, { jobId = 'rent-job', price = 500, size = 50, km = 1, deleted = 0 } = {}) => + const addListing = (id, { jobId = 'rent-job', price = 500, size = 50, km = 1, deleted = 0, currency = 'EUR' } = {}) => db .prepare( - `INSERT INTO listings (id, job_id, price, size, latitude, longitude, manually_deleted, price_per_sqm) - VALUES (?,?,?,?,?,?,?,?)`, + `INSERT INTO listings (id, job_id, price, currency, size, latitude, longitude, manually_deleted, price_per_sqm) + VALUES (?,?,?,?,?,?,?,?,?)`, ) - .run(id, jobId, price, size, LAT + km / 111.32, LNG, deleted, price / size); + .run(id, jobId, price, currency, size, LAT + km / 111.32, LNG, deleted, price / size); const rowOf = (id) => db.prepare(`SELECT * FROM listings WHERE id = ?`).get(id); + /** The listing being measured, the way the pipeline hands it over. */ + const target = (overrides = {}) => ({ id: 'target', currency: 'EUR', latitude: LAT, longitude: LNG, ...overrides }); + it('writes the median, the sample size and the radius onto the listing', () => { for (let index = 0; index < MIN_SAMPLE; index += 1) { addListing(`neighbour-${index}`, { price: 500 }); } addListing('target', { price: 400 }); - listingsStorage.applyMarketBenchmark('rent-job', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('rent-job', [target()]); const row = rowOf('target'); expect(row.market_median_sqm).toBe(10); @@ -105,7 +112,7 @@ describe('market benchmark against the listings table', () => { } addListing('target'); - listingsStorage.applyMarketBenchmark('rent-job', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('rent-job', [target()]); const row = rowOf('target'); expect(row.market_median_sqm).toBeNull(); @@ -123,7 +130,7 @@ describe('market benchmark against the listings table', () => { } addListing('target', { price: 480 }); - listingsStorage.applyMarketBenchmark('rent-job', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('rent-job', [target()]); expect(rowOf('target').market_median_sqm).toBe(10); }); @@ -137,7 +144,7 @@ describe('market benchmark against the listings table', () => { } addListing('target', { price: 500 }); - listingsStorage.applyMarketBenchmark('rent-job', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('rent-job', [target()]); expect(rowOf('target').market_median_sqm).toBe(10); }); @@ -150,7 +157,7 @@ describe('market benchmark against the listings table', () => { } addListing('target', { price: 5000 }); - listingsStorage.applyMarketBenchmark('rent-job', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('rent-job', [target()]); expect(rowOf('target').market_median_sqm).toBe(10); }); @@ -162,7 +169,7 @@ describe('market benchmark against the listings table', () => { } addListing('target', { jobId: 'undecided' }); - listingsStorage.applyMarketBenchmark('undecided', [{ id: 'target', latitude: LAT, longitude: LNG }]); + listingsStorage.applyMarketBenchmark('undecided', [target()]); expect(rowOf('target').market_median_sqm).toBeNull(); }); @@ -180,6 +187,23 @@ 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, currency: 'PLN' }); + const polish = () => target({ id: 'pln-target', currency: 'PLN' }); + listingsStorage.applyMarketBenchmark('buy-job', [polish()]); + expect(rowOf('pln-target').market_median_sqm).toBeNull(); + for (let index = 0; index < MIN_SAMPLE; index++) { + addListing(`pln-${index}`, { jobId: 'buy-job', price: 500000, currency: 'PLN' }); + } + listingsStorage.applyMarketBenchmark('buy-job', [polish()]); + expect(rowOf('pln-target').market_median_sqm).toBe(10000); + const kpis = listingsStorage.getListingsKpisForJobIds(['buy-job']); + 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/services/storage/listingsStorage.publishedAt.test.js b/test/services/storage/listingsStorage.publishedAt.test.js index 452af3bbf..eb71d4b11 100644 --- a/test/services/storage/listingsStorage.publishedAt.test.js +++ b/test/services/storage/listingsStorage.publishedAt.test.js @@ -38,6 +38,7 @@ const USER = 'user-1'; const listing = (hash, overrides = {}) => ({ id: hash, price: 1000, + currency: 'EUR', size: 60, rooms: 2, title: `Flat ${hash}`, @@ -67,6 +68,7 @@ describe('listings published_at', () => { provider TEXT, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, diff --git a/test/services/storage/listingsStorage.scam.test.js b/test/services/storage/listingsStorage.scam.test.js index b5d7e9417..3ed608c23 100644 --- a/test/services/storage/listingsStorage.scam.test.js +++ b/test/services/storage/listingsStorage.scam.test.js @@ -32,6 +32,7 @@ describe('scam signals and overrides in the listings table', () => { provider TEXT, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, @@ -152,6 +153,7 @@ describe('scam signals and overrides in the listings table', () => { { id: 'portal-hash', price: 500, + currency: 'EUR', size: 100, rooms: 3, title: 'Wohnung', diff --git a/test/services/tracking/priceObservation.test.js b/test/services/tracking/priceObservation.test.js index c31032ae8..4d634ba78 100644 --- a/test/services/tracking/priceObservation.test.js +++ b/test/services/tracking/priceObservation.test.js @@ -25,9 +25,10 @@ const ACCEPTED = () => jsonResponse({ status: 'ok', stored: true, band: '500-100 * @param {number|null} [overrides.perSqm] `null` stands for a job with no measured listing. * @returns {Object} */ -const kpisFor = ({ perSqm = 14.2, sampleSize = 47, total = 1180, dealType = 'rent' } = {}) => ({ +const kpisFor = ({ perSqm = 14.2, sampleSize = 47, total = 1180, dealType = 'rent', currency = 'EUR' } = {}) => ({ numberOfActiveListings: sampleSize, medianPriceOfListings: total, + currency, medianPricePerSqm: perSqm == null ? null : { dealType, value: perSqm, sampleSize }, }); @@ -133,6 +134,10 @@ describe('services/tracking - price observation', () => { }); }); + it('reports nothing for a job priced outside the euro, whose figure would read as one', async () => { + expect(await reportOne(jobWith(), { kpi: kpisFor({ currency: 'CHF' }) })).toBeUndefined(); + }); + describe('the price range', () => { const url = (query) => `https://portal.example/s?${query}`; diff --git a/test/storage/kpiMedian.test.js b/test/storage/kpiMedian.test.js index c88ac0cc3..a7ecc096a 100644 --- a/test/storage/kpiMedian.test.js +++ b/test/storage/kpiMedian.test.js @@ -25,7 +25,10 @@ describe('getListingsKpisForJobIds', () => { CREATE TABLE listings ( id TEXT PRIMARY KEY, job_id TEXT, + provider TEXT DEFAULT 'immoscout', + link TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', is_active INTEGER, manually_deleted INTEGER DEFAULT 0, price_per_sqm REAL @@ -53,10 +56,10 @@ describe('getListingsKpisForJobIds', () => { afterEach(() => db.close()); let seq = 0; - const add = (price, { jobId = 'job-1', isActive = 1, deleted = 0 } = {}) => + const add = (price, { jobId = 'job-1', isActive = 1, deleted = 0, currency = 'EUR' } = {}) => db - .prepare('INSERT INTO listings (id, job_id, price, is_active, manually_deleted) VALUES (?,?,?,?,?)') - .run(`l-${seq++}`, jobId, price, isActive, deleted); + .prepare('INSERT INTO listings (id, job_id, price, currency, is_active, manually_deleted) VALUES (?,?,?,?,?,?)') + .run(`l-${seq++}`, jobId, price, currency, isActive, deleted); const kpis = (jobIds = ['job-1']) => listingsStorage.getListingsKpisForJobIds(jobIds); @@ -76,6 +79,7 @@ describe('getListingsKpisForJobIds', () => { numberOfListings: 0, numberOfActiveListings: 0, medianPriceOfListings: 0, + currency: 'EUR', medianPriceSampleSize: 0, medianPricePerSqm: null, }; @@ -83,6 +87,46 @@ describe('getListingsKpisForJobIds', () => { expect(listingsStorage.getListingsKpisForJobIds()).toEqual(empty); }); + it('rejects a numeric price range spanning currencies before it filters either amount', () => { + db.exec('CREATE TABLE watch_list (id TEXT, listing_id TEXT, user_id TEXT)'); + add(1000); + add(1000000, { currency: 'PLN' }); + const message = 'Select one currency before filtering by price.'; + expect(() => listingsStorage.queryListings({ minPrice: 500000, isAdmin: true })).toThrow(message); + expect(() => listingsStorage.deleteListingsByFilter({ minPrice: 500000, isAdmin: true })).toThrow(message); + expect(db.prepare('SELECT COUNT(1) AS cnt FROM listings WHERE manually_deleted = 0').get().cnt).toBe(2); + }); + + it('does not count a listing without a price against a price range', () => { + db.exec('CREATE TABLE watch_list (id TEXT, listing_id TEXT, user_id TEXT)'); + add(1000); + add(null, { currency: 'CHF' }); + expect(listingsStorage.deleteListingsByFilter({ minPrice: 500, isAdmin: true }).changes).toBe(1); + }); + + it('leaves both medians out of a selection spanning currencies, and says so', () => { + add(1000); + add(1000000, { currency: 'PLN' }); + expect(kpis()).toMatchObject({ + numberOfListings: 2, + medianPriceOfListings: null, + currency: null, + medianPriceSampleSize: 0, + medianPricePerSqm: null, + }); + }); + + it('names the one currency a selection is priced in', () => { + add(1400, { currency: 'CHF' }); + expect(kpis()).toMatchObject({ medianPriceOfListings: 1400, currency: 'CHF' }); + }); + + it('is not put off a median by a listing that has no price to compare', () => { + add(1000); + add(null, { currency: 'CHF' }); + expect(kpis()).toMatchObject({ medianPriceOfListings: 1000, currency: 'EUR' }); + }); + it('counts only active listings', () => { add(1000); add(1100); @@ -140,6 +184,7 @@ describe('getListingsKpisForJobIds', () => { add(1000); add(50_000, { deleted: 1 }); expect(kpis()).toEqual({ + currency: 'EUR', numberOfListings: 1, numberOfActiveListings: 1, medianPriceOfListings: 1000, @@ -156,6 +201,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 +213,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/listingCurrencyMigration.test.js b/test/storage/listingCurrencyMigration.test.js new file mode 100644 index 000000000..8e95985de --- /dev/null +++ b/test/storage/listingCurrencyMigration.test.js @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; + +import { MIN_SAMPLE } from '../../lib/services/listings/marketBenchmark.js'; +import { up } from '../../lib/services/storage/migrations/sql/55.listing-currency.js'; + +const FLAT_IN_BASEL = 'https://flatfox.ch/en/flat/1/'; +const FLAT_IN_LOERRACH = 'https://www.immobilienscout24.de/expose/1'; + +/** Basel and Lörrach: one tram ride, two currencies. */ +const BASEL = { latitude: 47.5596, longitude: 7.5886 }; +const LOERRACH = { latitude: 47.6101, longitude: 7.6612 }; +const BERLIN = { latitude: 52.52, longitude: 13.405 }; + +/** + * Everything stored before this migration was treated as euros in the interface, and two sources + * were wrong about that. Leaving their rows on the default would keep francs in the euro medians + * forever, because nothing rewrites a stored listing's currency afterwards. + */ +describe('migration 55 - listing currency', () => { + let db; + + beforeEach(() => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE jobs (id TEXT PRIMARY KEY, deal_type TEXT); + CREATE TABLE listings ( + id TEXT PRIMARY KEY, + job_id TEXT, + provider TEXT, + link TEXT, + title TEXT, + description TEXT, + price REAL, + size REAL, + latitude REAL, + longitude REAL, + manually_deleted INTEGER DEFAULT 0, + price_per_sqm REAL, + market_median_sqm REAL, + market_sample_size INTEGER, + market_radius_km REAL, + scam_signals TEXT + ); + INSERT INTO jobs (id, deal_type) VALUES ('rent-job', 'rent'); + `); + }); + + afterEach(() => db.close()); + + const add = (id, provider, link) => + db.prepare(`INSERT INTO listings (id, provider, link) VALUES (?, ?, ?)`).run(id, provider, link); + const currencyOf = (id) => db.prepare(`SELECT currency FROM listings WHERE id = ?`).get(id).currency; + + /** A located rental, with the benchmark a previous run stored on it. */ + const addMeasured = (id, provider, link, place, { perSqm = 20, median = null, signals = null } = {}) => + db + .prepare( + `INSERT INTO listings (id, job_id, provider, link, price, size, latitude, longitude, price_per_sqm, + market_median_sqm, market_sample_size, market_radius_km, scam_signals) + VALUES (@id, 'rent-job', @provider, @link, @price, 50, @latitude, @longitude, @perSqm, @median, @sample, + @radius, @signals)`, + ) + .run({ + id, + provider, + link, + ...place, + perSqm, + price: perSqm * 50, + median, + sample: median == null ? null : MIN_SAMPLE, + radius: median == null ? null : 15, + signals, + }); + + const benchmarkOf = (id) => + db + .prepare( + `SELECT market_median_sqm AS median, market_sample_size AS sample, scam_signals AS signals + FROM listings WHERE id = ?`, + ) + .get(id); + + const addNeighbours = (provider, link, place, perSqm, count = MIN_SAMPLE) => { + for (let index = 0; index < count; index += 1) { + addMeasured(`${provider}-${index}`, provider, `${link}${index}`, place, { perSqm }); + } + }; + + it('moves every flatfox listing to francs', () => { + add('zurich', 'flatfox', FLAT_IN_BASEL); + up(db); + + expect(currencyOf('zurich')).toBe('CHF'); + }); + + it('tells the Swiss site of BETTERHOMES from the German and Austrian ones', () => { + add('bern', 'betterhomes', 'https://www.betterhomes.ch/de/immobilie-suchen/detail/objectId/1'); + add('basel', 'betterhomes', 'https://betterhomes.ch/de/immobilie-suchen/detail/objectId/2'); + add('berlin', 'betterhomes', 'https://www.betterhomes.de/de/immobilie-suchen/detail/objectId/3'); + add('wien', 'betterhomes', 'https://www.betterhomes.at/de/immobilie-suchen/detail/objectId/4'); + up(db); + + expect(currencyOf('bern')).toBe('CHF'); + expect(currencyOf('basel')).toBe('CHF'); + expect(currencyOf('berlin')).toBe('EUR'); + expect(currencyOf('wien')).toBe('EUR'); + }); + + it('leaves everything else in euros, whatever its link says', () => { + add('munich', 'immoscout', FLAT_IN_LOERRACH); + add('mirror', 'kleinanzeigen', 'https://www.kleinanzeigen.de/?ref=https://www.betterhomes.ch/'); + up(db); + + expect(currencyOf('munich')).toBe('EUR'); + expect(currencyOf('mirror')).toBe('EUR'); + }); + + it('never leaves a row without a currency', () => { + add('no-link', 'immoscout', null); + up(db); + add('later', 'immoscout', null); + + expect(currencyOf('no-link')).toBe('EUR'); + expect(currencyOf('later')).toBe('EUR'); + expect(() => db.prepare(`UPDATE listings SET currency = NULL WHERE id = 'later'`).run()).toThrow(/NOT NULL/); + }); + + it('is a no-op when run again', () => { + add('zurich', 'flatfox', FLAT_IN_BASEL); + up(db); + + expect(() => up(db)).not.toThrow(); + expect(currencyOf('zurich')).toBe('CHF'); + }); + + describe('the stored benchmarks', () => { + it('clears a median taken over another currency when its own has too few listings', () => { + addNeighbours('immoscout', FLAT_IN_LOERRACH, LOERRACH, 20); + addMeasured('target', 'flatfox', FLAT_IN_BASEL, BASEL, { perSqm: 10, median: 20 }); + up(db); + + expect(benchmarkOf('target')).toMatchObject({ median: null, sample: null }); + }); + + it('withdraws a scam signal that only fired against the wrong market', () => { + addNeighbours('immoscout', FLAT_IN_LOERRACH, LOERRACH, 20); + addMeasured('target', 'flatfox', FLAT_IN_BASEL, BASEL, { + perSqm: 10, + median: 20, + signals: '["priceFarBelowMarket"]', + }); + up(db); + + expect(benchmarkOf('target').signals).toBeNull(); + }); + + it('measures a listing again against its own currency alone', () => { + addNeighbours('immoscout', FLAT_IN_LOERRACH, LOERRACH, 20); + addNeighbours('flatfox', FLAT_IN_BASEL, BASEL, 40); + addMeasured('target', 'immoscout', FLAT_IN_LOERRACH, LOERRACH, { median: 30 }); + up(db); + + expect(benchmarkOf('target')).toMatchObject({ median: 20, sample: MIN_SAMPLE }); + }); + + it('keeps the benchmark of a listing with no other currency anywhere near it', () => { + addNeighbours('flatfox', FLAT_IN_BASEL, BASEL, 40); + addMeasured('far-away', 'immoscout', FLAT_IN_LOERRACH, BERLIN, { median: 18 }); + up(db); + + expect(benchmarkOf('far-away')).toMatchObject({ median: 18, sample: MIN_SAMPLE }); + }); + + it('touches nothing on an instance holding one currency', () => { + addNeighbours('immoscout', FLAT_IN_LOERRACH, LOERRACH, 20); + addMeasured('target', 'immoscout', FLAT_IN_LOERRACH, LOERRACH, { median: 30 }); + up(db); + + expect(benchmarkOf('target')).toMatchObject({ median: 30 }); + }); + }); +}); diff --git a/test/storage/listingMediaStorage.test.js b/test/storage/listingMediaStorage.test.js index 5e9de99bc..8a5bd66bd 100644 --- a/test/storage/listingMediaStorage.test.js +++ b/test/storage/listingMediaStorage.test.js @@ -26,6 +26,7 @@ describe('listing media storage', () => { provider TEXT, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, @@ -226,6 +227,7 @@ describe('listing media storage', () => { const listing = (hash, overrides = {}) => ({ id: hash, price: 900, + currency: 'EUR', size: 50, rooms: 2, title: `Flat ${hash}`, diff --git a/test/storage/priceHistoryStorage.test.js b/test/storage/priceHistoryStorage.test.js index d0471e2a7..009123eab 100644 --- a/test/storage/priceHistoryStorage.test.js +++ b/test/storage/priceHistoryStorage.test.js @@ -36,6 +36,7 @@ describe('price history storage', () => { address TEXT, link TEXT, price INTEGER, + currency TEXT NOT NULL DEFAULT 'EUR', size INTEGER, rooms INTEGER, image_url TEXT, @@ -130,6 +131,20 @@ describe('price history storage', () => { expect(row).not.toHaveProperty('image'); }); + it('prices a change in the currency stored on the row', () => { + addListing('a'); + db.prepare(`UPDATE listings SET currency = 'CHF' WHERE id = 'a'`).run(); + const [row] = storage.getListingsDueForPriceCheck({ now: NOW }); + const change = formatPriceChange({ + listing: { ...row, price: 1100 }, + oldPrice: 1000, + newPrice: 1100, + changePercent: 10, + direction: 'up', + }); + expect(change).toMatchObject({ price: '1.100 CHF', oldPrice: '1.000 CHF', newPrice: '1.100 CHF' }); + }); + it('leaves out inactive and manually deleted listings', () => { addListing('active'); addListing('gone', { isActive: 0 }); diff --git a/test/storage/queryListingsAffordability.test.js b/test/storage/queryListingsAffordability.test.js index 6144d4396..805df1bea 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 }); @@ -38,6 +38,13 @@ describe('queryListings affordabilityBand', () => { listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); }); + it('holds only euro listings against the band', () => { + listingsStorage.queryListings({ userId: 'u1', affordabilityBand: buyOnly(30000, 412000) }); + const { sql, params } = pageQuery(); + expect(sql).toContain('l.currency = @affordabilityCurrency'); + expect(params.affordabilityCurrency).toBe('EUR'); + }); + it('adds no clause when no band is given', () => { listingsStorage.queryListings({ userId: 'u1' }); diff --git a/test/storage/queryListingsAffordabilitySql.test.js b/test/storage/queryListingsAffordabilitySql.test.js index fbaf9e57d..9b89325a5 100644 --- a/test/storage/queryListingsAffordabilitySql.test.js +++ b/test/storage/queryListingsAffordabilitySql.test.js @@ -70,6 +70,7 @@ describe('queryListings affordability band against real SQLite', () => { id TEXT PRIMARY KEY, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, @@ -208,6 +209,45 @@ describe('queryListings affordability band against real SQLite', () => { expect(result.result.map((row) => row.id)).toEqual(['buy-at-ceiling']); }); + describe('with listings in another currency', () => { + beforeEach(() => { + db.prepare(`INSERT INTO listings (id, job_id, price, currency, title) VALUES (?, ?, ?, ?, ?)`).run( + 'buy-francs', + 'job-buy', + 120000, + 'CHF', + 'buy-francs', + ); + }); + + const query = (filters) => listingsStorage.queryListings({ userId: USER, pageSize: 100, ...filters }); + const ids = (filters) => + query(filters) + .result.map((row) => row.id) + .sort(); + + it('carries the currency on every row', () => { + const byId = Object.fromEntries(query({}).result.map((row) => [row.id, row.currency])); + expect(byId['buy-francs']).toBe('CHF'); + expect(byId['buy-cheap']).toBe('EUR'); + }); + + it('never counts francs as affordable in euros', () => { + expect(idsFor({ buy: { min: 30000, max: 130000 }, rent: null })).toEqual(['buy-cheap']); + }); + + it('takes a price range only once the selection is down to one currency', () => { + expect(() => query({ minPrice: 100000 })).toThrow('Select one currency before filtering by price.'); + expect(ids({ minPrice: 100000, currencyFilter: 'CHF' })).toEqual(['buy-francs']); + expect(ids({ minPrice: 100000, maxPrice: 130000, currencyFilter: 'EUR' })).toEqual(['buy-cheap']); + }); + + it('needs no currency of its own when the band has already narrowed to euros', () => { + const band = { buy: { min: 30000, max: 130000 }, rent: null }; + expect(ids({ minPrice: 100000, affordabilityBand: band })).toEqual(['buy-cheap']); + }); + }); + it('reports a total that matches the filtered rows, so pagination stays honest', () => { const band = { buy: { min: 30000, max: 263511.69669265934 }, rent: { minExclusive: 0, max: 1120 } }; const result = listingsStorage.queryListings({ userId: USER, pageSize: 100, affordabilityBand: band }); diff --git a/test/storage/storeListingsCharges.test.js b/test/storage/storeListingsCharges.test.js index c6a9297b7..e40373dd0 100644 --- a/test/storage/storeListingsCharges.test.js +++ b/test/storage/storeListingsCharges.test.js @@ -23,6 +23,7 @@ describe('storeListings, the basis of a rent', () => { provider TEXT, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, @@ -66,6 +67,7 @@ describe('storeListings, the basis of a rent', () => { const listing = (hash, overrides = {}) => ({ id: hash, price: 1140, + currency: 'EUR', size: 37, rooms: 2, title: `Flat ${hash}`, diff --git a/test/storage/storeListingsConflict.test.js b/test/storage/storeListingsConflict.test.js index 62fecc96c..1f48d8af8 100644 --- a/test/storage/storeListingsConflict.test.js +++ b/test/storage/storeListingsConflict.test.js @@ -26,6 +26,7 @@ describe('storeListings id propagation', () => { provider TEXT, job_id TEXT, price REAL, + currency TEXT NOT NULL DEFAULT 'EUR', charges_included INTEGER, charges REAL, size REAL, @@ -72,6 +73,7 @@ describe('storeListings id propagation', () => { const listing = (hash, overrides = {}) => ({ id: hash, price: 1000, + currency: 'EUR', size: 60, rooms: 2, title: `Flat ${hash}`, @@ -152,6 +154,21 @@ describe('storeListings id propagation', () => { expect(db.prepare('SELECT added_manually FROM listings WHERE id = ?').get(found[0].id).added_manually).toBe(0); }); + it('stores the currency the listing carries', () => { + const listings = [listing('francs', { currency: 'CHF' })]; + listingsStorage.storeListings('job-1', 'flatfox', listings); + + expect(db.prepare('SELECT currency FROM listings WHERE id = ?').get(listings[0].id).currency).toBe('CHF'); + }); + + // Defaulting here is how a franc price ends up stored as euros, with nothing to show for it. + it('refuses a listing that does not say its currency', () => { + expect(() => listingsStorage.storeListings('job-1', 'flatfox', [listing('x', { currency: undefined })])).toThrow( + /NOT NULL/, + ); + expect(db.prepare('SELECT COUNT(1) AS cnt FROM listings').get().cnt).toBe(0); + }); + it('strips parenthesised address suffixes', () => { const listings = [listing('hash-addr')]; listingsStorage.storeListings('job-1', 'immowelt', listings); diff --git a/test/ui/dashboardLayout.test.js b/test/ui/dashboardLayout.test.js index 4eff0b8cc..ce3692940 100644 --- a/test/ui/dashboardLayout.test.js +++ b/test/ui/dashboardLayout.test.js @@ -30,6 +30,18 @@ const SPACING_PROPERTIES = /** A translator that shows which key and parameters it was asked for. */ const t = (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key); +describe('the medians across currencies', () => { + // The price per m² tile used to fall through to "waiting for listings with a size", which is not + // why it is empty, and the median price tile said nothing at all. + it('says why both tiles are empty rather than leaving them to the pending text', () => { + expect(jsx).toMatch(/const mixedCurrencies = kpis\.currency === null/); + expect(jsx.match(/t\('dashboard\.kpiMixedCurrencies'\)/g)).toHaveLength(2); + expect(jsx.indexOf("t('dashboard.kpiMixedCurrencies')", jsx.indexOf('kpiMedianSqm'))).toBeLessThan( + jsx.indexOf("t('dashboard.kpiMedianSqmPending')"), + ); + }); +}); + describe('Dashboard.less', () => { it('takes every spacing value from the scale, never a bare number', () => { const offenders = []; 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/listingCurrency.test.js b/test/ui/listingCurrency.test.js new file mode 100644 index 000000000..3af0ce954 --- /dev/null +++ b/test/ui/listingCurrency.test.js @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf-8'); + +describe('a listing outside the euro', () => { + // The hint's button opens the calculator with the listing's price filled in, as euros. + it('is not offered the finance calculator', () => { + expect(read('ui/src/views/listings/ListingDetail.jsx')).toMatch( + /const financeIncomplete =\s+listing\.currency === 'EUR'/, + ); + }); +}); + +describe('the map price filter', () => { + // A bound from a bookmark hides nothing across currencies, so it must not read as active. + it('does not count a price bound it is not applying', () => { + const map = read('ui/src/views/listings/Map.jsx'); + expect(map).toMatch(/priceMin: mixedCurrencies \? null : urlPriceMin/); + expect(map).toMatch(/priceMax: mixedCurrencies \? null : urlPriceMax/); + }); + + // One "price on request" advert in francs must not take the slider away from a page of euros. + it('decides whether currencies are mixed from priced listings only', () => { + expect(read('ui/src/views/listings/Map.jsx')).toMatch( + /const currencies = \[\.\.\.new Set\(listings\.filter\(\(listing\) => listing\.price\)/, + ); + }); +}); 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..21c4e54ed 100644 --- a/test/ui/mapTheme.test.js +++ b/test/ui/mapTheme.test.js @@ -251,7 +251,7 @@ describe('the page', () => { it('counts the filters a folded panel hides, so an emptier map explains itself', () => { expect(viewJsx).toMatch(/activeCount=\{activeFilterCount\}/); expect(viewJsx).toMatch( - /const activeFilterCount = countActiveMapFilters\(\{\s*jobId,\s*commute: commuteFilter,\s*priceMin: urlPriceMin,\s*priceMax: urlPriceMax,?\s*\}\);/, + /const activeFilterCount = countActiveMapFilters\(\{\s*jobId,\s*commute: commuteFilter,\s*priceMin: mixedCurrencies \? null : urlPriceMin,\s*priceMax: mixedCurrencies \? null : urlPriceMax,?\s*\}\);/, ); }); diff --git a/test/ui/priceFormat.test.js b/test/ui/priceFormat.test.js index 90ac5f669..d31fee66e 100644 --- a/test/ui/priceFormat.test.js +++ b/test/ui/priceFormat.test.js @@ -4,8 +4,8 @@ */ import { describe, it, expect } from 'vitest'; -import { formatEuroPrice } from '../../ui/src/services/price/priceService.js'; -import { formatEuro } from '../../ui/src/components/cards/chartTheme.js'; +import { formatListingPrice } from '../../ui/src/services/price/priceService.js'; +import { formatEuro, formatEuroCompact } from '../../ui/src/components/cards/chartTheme.js'; /** * `Intl` separates the amount from the currency symbol with a narrow no-break space, which is @@ -15,54 +15,59 @@ import { formatEuro } from '../../ui/src/components/cards/chartTheme.js'; */ const normalize = (value) => value.replace(/[\u00a0\u202f]/g, ' '); -describe('formatEuroPrice', () => { +describe('formatListingPrice', () => { + 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 €'); + expect(normalize(formatListingPrice(776515, 'de-DE'))).toBe('776.515 €'); }); it('follows the reader locale for separator and symbol position', () => { - expect(normalize(formatEuroPrice(776515, 'en-US'))).toBe('€776,515'); + expect(normalize(formatListingPrice(776515, 'en-US'))).toBe('€776,515'); }); it('drops the cents a whole price does not have', () => { - expect(normalize(formatEuroPrice(1016, 'de-DE'))).toBe('1.016 €'); + expect(normalize(formatListingPrice(1016, 'de-DE'))).toBe('1.016 €'); }); it('prints both digits when a price does carry cents', () => { - expect(normalize(formatEuroPrice(1016.5, 'de-DE'))).toBe('1.016,50 €'); - expect(normalize(formatEuroPrice(1016.55, 'de-DE'))).toBe('1.016,55 €'); + expect(normalize(formatListingPrice(1016.5, 'de-DE'))).toBe('1.016,50 €'); + expect(normalize(formatListingPrice(1016.55, 'de-DE'))).toBe('1.016,55 €'); }); it('accepts the numeric strings the storage layer hands back', () => { - expect(normalize(formatEuroPrice('776515', 'de-DE'))).toBe('776.515 €'); + expect(normalize(formatListingPrice('776515', 'de-DE'))).toBe('776.515 €'); }); it('formats in German when no locale is given', () => { - expect(normalize(formatEuroPrice(776515))).toBe('776.515 €'); + expect(normalize(formatListingPrice(776515))).toBe('776.515 €'); }); it('falls back to German rather than throwing on an empty locale', () => { - expect(normalize(formatEuroPrice(776515, ''))).toBe('776.515 €'); + expect(normalize(formatListingPrice(776515, ''))).toBe('776.515 €'); }); it('hands back text prices unchanged, as providers wrote them', () => { - expect(formatEuroPrice('auf Anfrage', 'de-DE')).toBe('auf Anfrage €'); + expect(formatListingPrice('auf Anfrage', 'de-DE')).toBe('auf Anfrage €'); }); it('formats zero rather than treating it as missing', () => { - expect(normalize(formatEuroPrice(0, 'de-DE'))).toBe('0 €'); + expect(normalize(formatListingPrice(0, 'de-DE'))).toBe('0 €'); }); it('reuses one formatter per locale and precision', () => { // Same output on the second call is the only observable proof the cache did not go stale. - expect(formatEuroPrice(1234, 'de-DE')).toBe(formatEuroPrice(1234, 'de-DE')); - expect(normalize(formatEuroPrice(1234, 'en-US'))).toBe('€1,234'); + expect(formatListingPrice(1234, 'de-DE')).toBe(formatListingPrice(1234, 'de-DE')); + expect(normalize(formatListingPrice(1234, 'en-US'))).toBe('€1,234'); }); }); describe('formatEuro (charts)', () => { it('shares the listing format so a chart and a card read alike', () => { - expect(normalize(formatEuro(776515, 'de-DE'))).toBe(normalize(formatEuroPrice(776515, 'de-DE'))); + expect(normalize(formatEuro(776515, 'de-DE'))).toBe(normalize(formatListingPrice(776515, 'de-DE'))); }); it('rounds away the cents an axis label has no room for', () => { @@ -73,4 +78,15 @@ describe('formatEuro (charts)', () => { expect(formatEuro(null, 'de-DE')).toBe('–'); expect(formatEuro('auf Anfrage', 'de-DE')).toBe('–'); }); + + it('rounds another currency the same way', () => { + expect(normalize(formatEuro(1016.55, 'de-DE', 'CHF'))).toBe('1.017 CHF'); + }); +}); + +describe('formatEuroCompact', () => { + it('stays compact whatever the currency, which a purchase price under the map slider needs', () => { + expect(normalize(formatEuroCompact(1250000, 'de-DE'))).toBe('1,3 Mio. €'); + expect(normalize(formatEuroCompact(1250000, 'de-DE', 'CHF'))).toBe('1,3 Mio. CHF'); + }); }); diff --git a/test/utils/formatListingCurrency.test.js b/test/utils/formatListingCurrency.test.js index 615c190e7..45ca60036 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'); + expect(formatListing({ price: 1440, currency: 'CHF' }, 'de').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/cards/chartTheme.js b/ui/src/components/cards/chartTheme.js index bc7f6df4f..46af8c5fe 100644 --- a/ui/src/components/cards/chartTheme.js +++ b/ui/src/components/cards/chartTheme.js @@ -17,7 +17,7 @@ import { Tooltip, } from 'chart.js'; -import { formatEuroPrice } from '../../services/price/priceService.js'; +import { formatListingPrice } from '../../services/price/priceService.js'; /* * Chart colours and typography, read from the theme rather than copied out of it. @@ -172,16 +172,17 @@ export function registerFinanceCharts() { * * @param {number} value * @param {string} [locale='de-DE'] + * @param {string} [currency='EUR'] ISO 4217 code, for a listing priced in something else. * @returns {string} */ -export function formatEuro(value, locale = 'de-DE') { +export function formatEuro(value, locale = 'de-DE', currency = 'EUR') { if (value == null || !Number.isFinite(Number(value))) { return '–'; } - // Rounded before it is handed over: `formatEuroPrice` keeps the cents a value actually has, and + // Rounded before it is handed over: `formatListingPrice` keeps the cents a value actually has, and // on an axis label or a tooltip those two digits are exactly the noise this function exists to // drop. What is left is the same grouping and symbol the listings use. - return formatEuroPrice(Math.round(Number(value)), locale); + return formatListingPrice(Math.round(Number(value)), locale, currency); } /** @@ -189,15 +190,16 @@ export function formatEuro(value, locale = 'de-DE') { * * @param {number} value * @param {string} [locale='de-DE'] + * @param {string} [currency='EUR'] ISO 4217 code, for a listing priced in something else. * @returns {string} */ -export function formatEuroCompact(value, locale = 'de-DE') { +export function formatEuroCompact(value, locale = 'de-DE', currency = 'EUR') { if (value == null || !Number.isFinite(Number(value))) { return ''; } return new Intl.NumberFormat(locale, { style: 'currency', - currency: 'EUR', + currency, notation: 'compact', maximumFractionDigits: 1, }).format(Number(value)); 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)} + {/* The population the median is taken over. Without it, two medians that cannot be reconciled sit next to each other. */} @@ -285,20 +287,25 @@ export default function Dashboard() { {t('dashboard.kpiMedianPriceDesc', { count: String(kpis.medianPriceSampleSize) })} )} + {mixedCurrencies && {t('dashboard.kpiMixedCurrencies')}} diff --git a/ui/src/views/dashboard/components/LatestListings.jsx b/ui/src/views/dashboard/components/LatestListings.jsx index a66918d66..1d7b90f74 100644 --- a/ui/src/views/dashboard/components/LatestListings.jsx +++ b/ui/src/views/dashboard/components/LatestListings.jsx @@ -5,7 +5,7 @@ import { IconArrowRight } from '../../../components/icons/StrokeIcons.jsx'; import ListingPhoto from '../../../components/listings/ListingPhoto.jsx'; -import { formatEuroPrice } from '../../../services/price/priceService.js'; +import { formatListingPrice } from '../../../services/price/priceService.js'; import { formatDeviation, formatPricePerSqm, readMarketBenchmark } from '../../../services/listings/marketBenchmark.js'; import { listingPictureSrc } from '../../../services/listings/listingPictures.js'; import { formatDecimal } from '../../../services/number/numberService.js'; @@ -97,10 +97,12 @@ export default function LatestListings({ {/* `price` is nullable (only id, link and title are required), and formatting null prints "0 €". */} - {row.price != null ? formatEuroPrice(row.price, locale) : t('common.na')} + {row.price != null ? formatListingPrice(row.price, locale, row.currency) : t('common.na')} {row.price_per_sqm != null && ( - {formatPricePerSqm(row.price_per_sqm, locale)} + + {formatPricePerSqm(row.price_per_sqm, locale, true, row.currency)} + )} {row.title} diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx index 075b86be4..84bb0bebd 100644 --- a/ui/src/views/jobs/mutation/JobMutation.jsx +++ b/ui/src/views/jobs/mutation/JobMutation.jsx @@ -32,6 +32,7 @@ import { missingRequirements } from '../../../services/jobs/jobValidation.js'; import { summariseJobRefinements } from '../../../services/jobs/jobSummary.js'; import { withReturnTo } from '../../../services/routes/returnTo.js'; import { formatEuro } from '../../../components/cards/chartTheme.js'; +import { normalizeHost } from '../../../services/jobs/providerUrl.js'; // The frontend copy of the server's detection. The two must agree: the pipeline falls back to its // own when a job carries no deal type, so a form that guessed differently would show one thing and // store another. Kept in step by test/ui/dealTypeCopyInSync.test.js. @@ -129,6 +130,7 @@ function JobMutator({ source }) { const shareableUserList = useSelector((state) => state.jobsData.shareableUserList); const allChannels = useSelector((state) => state.notificationChannels.channels); + const providerMetadata = useSelector((state) => state.provider); const location = useLocation(); const sourceJob = source.job; const isEdit = source.mode === 'edit'; @@ -404,10 +406,22 @@ function JobMutator({ source }) { }; }; + const currencies = [ + ...new Set( + providerData + .filter((p) => p.enabled !== false) + .map((p) => { + const meta = providerMetadata.find((candidate) => candidate.id === p.id); + return meta?.currencyByHost?.[normalizeHost(p.url)] ?? meta?.currency ?? 'EUR'; + }), + ), + ]; + const currency = currencies.length === 1 ? currencies[0] : null; + // What the collapsed section holds, so it does not have to be opened to find out. const refinementSummary = summariseJobRefinements( { blacklist, excludedLocations, specFilter, spatialFilter, commuteFilter }, - { t, formatPrice: (value) => formatEuro(value, locale) }, + { t, formatPrice: (value) => formatEuro(value, locale, currency ?? 'EUR') }, ); const handleProviderEdit = (data) => { @@ -753,10 +767,18 @@ function JobMutator({ source }) { helpText={t('jobs.mutation.criteriaFilterHelp')} helpMode="popover" > + {/* Not the map's "select one currency" wording: a job's mixed selection cannot be + priced at all. The field below stays editable on purpose - a job saved before + currencies were resolved can still carry a ceiling, and clearing it is the only + way to make that job saveable again. */} + {currencies.length > 1 &&

{t('jobs.mutation.mixedCurrencyMaxPrice')}

}
{SPEC_FILTERS.map((filter) => (
-
{filter.translation}
+
+ {filter.translation} + {filter.key === 'maxPrice' && currency ? ` (${currency})` : ''} +
diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index 3adfa84dc..7f3fa41cf 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -197,13 +197,19 @@ export default function MapView() { const priceCommitTimer = useRef(null); useEffect(() => () => clearTimeout(priceCommitTimer.current), []); + // Priced listings only, as on the server: a price bound drops the others in any currency. + const currencies = [...new Set(listings.filter((listing) => listing.price).map((listing) => listing.currency))]; + const mixedCurrencies = currencies.length > 1; + const currency = currencies[0] ?? 'EUR'; + // Read from the address bar rather than from the controls, which the folded panel on a phone does - // not render: what is on in the URL is what is hiding pins. + // not render: what is on in the URL is what is hiding pins. A price bound hides none across + // currencies, so it does not count there. const activeFilterCount = countActiveMapFilters({ jobId, commute: commuteFilter, - priceMin: urlPriceMin, - priceMax: urlPriceMax, + priceMin: mixedCurrencies ? null : urlPriceMin, + priceMax: mixedCurrencies ? null : urlPriceMax, }); const screenWidth = useScreenWidth(); @@ -263,7 +269,9 @@ export default function MapView() { // one it has nothing to fail, and hiding it always left it off the map with no filter active. const priceLimited = urlPriceMin != null || urlPriceMax != null; return listings - .filter((listing) => (listing.price ? listing.price >= min && listing.price <= max : !priceLimited)) + .filter( + (listing) => mixedCurrencies || (listing.price ? listing.price >= min && listing.price <= max : !priceLimited), + ) .filter(withinCommute); }; @@ -804,13 +812,23 @@ export default function MapView() { {t('map.filterPriceLabel')} -
-
- {formatEuroCompact(priceRange[0], locale)} - {formatEuroCompact(priceRange[1] || getMaxPrice(), locale)} + {mixedCurrencies ? ( + {t('listings.mixedCurrencyPriceFilter')} + ) : ( +
+
+ {formatEuroCompact(priceRange[0], locale, currency)} + {formatEuroCompact(priceRange[1] || getMaxPrice(), locale, currency)} +
+
- -
+ )}
{!hasHome && ( diff --git a/ui/src/views/listings/components/ListingFinanceCard.jsx b/ui/src/views/listings/components/ListingFinanceCard.jsx index 4d0baa88b..b4612c876 100644 --- a/ui/src/views/listings/components/ListingFinanceCard.jsx +++ b/ui/src/views/listings/components/ListingFinanceCard.jsx @@ -56,6 +56,10 @@ export default function ListingFinanceCard({ listing }) { }; }, [listing?.id, listing?.price]); + if (finance?.unsupportedCurrency) { + return {t('listing.detail.financeCurrencyUnsupported')}; + } + if (isRental) { return finance?.scored == null ? null : ( diff --git a/ui/src/views/listings/components/ListingKeyFacts.jsx b/ui/src/views/listings/components/ListingKeyFacts.jsx index f149c7696..e9b2e4b15 100644 --- a/ui/src/views/listings/components/ListingKeyFacts.jsx +++ b/ui/src/views/listings/components/ListingKeyFacts.jsx @@ -129,7 +129,7 @@ export default function ListingKeyFacts({ listing, priceHistory, financeThreshol {priceHistory.length >= 2 && (

{t('listing.detail.priceHistory')}

- +
)} diff --git a/ui/src/views/listings/components/PriceHistoryChart.jsx b/ui/src/views/listings/components/PriceHistoryChart.jsx index 5590c783b..17748a3a6 100644 --- a/ui/src/views/listings/components/PriceHistoryChart.jsx +++ b/ui/src/views/listings/components/PriceHistoryChart.jsx @@ -28,9 +28,10 @@ registerFinanceCharts(); * @param {Array<{price: number, observed_at: number}>} props.data Oldest first. * @param {string} [props.locale] * @param {number} [props.height=120] + * @param {string} [props.currency='EUR'] ISO 4217 code of the prices. * @returns {React.ReactElement|null} */ -export default function PriceHistoryChart({ data = [], locale = 'de-DE', height = 120 }) { +export default function PriceHistoryChart({ data = [], locale = 'de-DE', height = 120, currency = 'EUR' }) { const rows = React.useMemo(() => (Array.isArray(data) ? data : []), [data]); const chartData = React.useMemo( @@ -71,7 +72,7 @@ export default function PriceHistoryChart({ data = [], locale = 'de-DE', height color: CHART_COLORS.MUTED, font: { family: CHART_COLORS.FONT_UI, size: 12 }, maxTicksLimit: 4, - callback: (value) => formatEuro(value, locale), + callback: (value) => formatEuro(value, locale, currency), }, }, }, @@ -89,13 +90,13 @@ export default function PriceHistoryChart({ data = [], locale = 'de-DE', height bodyFont: { family: CHART_COLORS.FONT_UI, size: 13, weight: '600' }, callbacks: { title: (items) => new Date(Number(items[0].label)).toLocaleDateString(locale), - label: (ctx) => formatEuro(ctx.parsed.y, locale), + label: (ctx) => formatEuro(ctx.parsed.y, locale, currency), }, }, }, interaction: { mode: 'index', intersect: false }, }), - [locale], + [locale, currency], ); if (rows.length < 2) { diff --git a/ui/src/views/listings/listingFacts.js b/ui/src/views/listings/listingFacts.js index cc991ecc3..52200e7dd 100644 --- a/ui/src/views/listings/listingFacts.js +++ b/ui/src/views/listings/listingFacts.js @@ -3,7 +3,7 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { formatEuroPrice } from '../../services/price/priceService.js'; +import { formatListingPrice } from '../../services/price/priceService.js'; import { formatDecimal } from '../../services/number/numberService.js'; import { formatPricePerSqm, readMarketBenchmark } from '../../services/listings/marketBenchmark.js'; import { rentIncludesCharges, verdictExplanation } from '../../services/finance/rentBasis.js'; @@ -68,10 +68,10 @@ export function buildObjectFacts(listing, ctx) { const isRental = row.dealType === 'rent'; const price = { - text: row.price != null ? formatEuroPrice(row.price, locale) : na, + text: row.price != null ? formatListingPrice(row.price, locale, row.currency) : na, // Split so the page can set the digits large and leave the currency symbol at reading size. // A 40px euro sign is louder than the sum it belongs to, and every listing has the same one. - ...splitPrice(row.price, locale, na), + ...splitPrice(row.price, locale, na, row.currency), // Names what the number is, which the number alone cannot: the same "1.200 EUR" is a monthly // rent on one listing and a purchase price on the next, and a rent is quoted with the charges // on some portals and without them on the rest. @@ -97,7 +97,7 @@ export function buildObjectFacts(listing, ctx) { label: t('listing.detail.fieldPricePerSqm'), // Without the unit: the tile's own label already says "per m²", and repeating it there is // what pushed the figure past the width of a third of the rail. - value: benchmark != null ? formatPricePerSqm(benchmark.pricePerSqm, locale, false) : null, + value: benchmark != null ? formatPricePerSqm(benchmark.pricePerSqm, locale, false, row.currency) : null, // Two different explanations. With a benchmark the interesting part is the comparison and // where it came from; without one it is why no comparison is shown, which is a question the // page would otherwise leave the reader to guess at. @@ -133,15 +133,16 @@ function priceReferenceKey(row, isRental) { * @param {number|null|undefined} value * @param {string} locale * @param {string} fallback - Shown when there is no price at all. + * @param {string} [currency='EUR'] ISO 4217 code. * @returns {{amount: string, currency: string|null}} */ -function splitPrice(value, locale, fallback) { +function splitPrice(value, locale, fallback, currency = 'EUR') { if (value == null) return { amount: fallback, currency: null }; try { const parts = new Intl.NumberFormat(locale, { style: 'currency', - currency: 'EUR', + currency, maximumFractionDigits: 0, }).formatToParts(value); @@ -154,7 +155,7 @@ function splitPrice(value, locale, fallback) { }; } catch { // An unknown locale is not worth an empty price; the joined form is still readable. - return { amount: formatEuroPrice(value, locale), currency: null }; + return { amount: formatListingPrice(value, locale, currency), currency: null }; } } diff --git a/ui/src/views/listings/listingPopupContent.jsx b/ui/src/views/listings/listingPopupContent.jsx index 6d7139d79..22044446b 100644 --- a/ui/src/views/listings/listingPopupContent.jsx +++ b/ui/src/views/listings/listingPopupContent.jsx @@ -7,7 +7,7 @@ import { renderToString } from 'react-dom/server'; import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons'; import { listingPictureSrc } from '../../services/listings/listingPictures.js'; import { availableModes, formatMinutes, hasAnyTime } from '../../components/transit/travelTimeFormat.js'; -import { formatEuroPrice } from '../../services/price/priceService.js'; +import { formatListingPrice } from '../../services/price/priceService.js'; import { formatDecimal } from '../../services/number/numberService.js'; import { mountPopupNode } from '../../components/map/popupContent.jsx'; import MapPopupActions from './components/MapPopupActions.jsx'; @@ -215,7 +215,9 @@ function renderListingBody(listing, index, total, t, locale) { // Laid out like a listing card: the photograph, the price in the UI face with the living space // muted beside it, the title, the address. Only the two facts that are Fredy's own bookkeeping // keep a label, because a search's name and a portal's are not self-explanatory on their own. - const price = listing.price ? escapeHtml(formatEuroPrice(listing.price, locale)) : t('common.na'); + const price = listing.price + ? escapeHtml(formatListingPrice(listing.price, locale, listing.currency)) + : t('common.na'); const size = listing.size != null ? `${escapeHtml(formatDecimal(listing.size, locale))} m²` : ''; // No picture, or one that fails to load, leaves the popup without one rather than showing the old