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..e477c210b 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 `54.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 5039cf053..e6352cea5 100644 --- a/doc/providers.md +++ b/doc/providers.md @@ -208,6 +208,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 89d6f5f5d..5723806ec 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'; @@ -584,11 +585,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( @@ -604,11 +601,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 eb33ced63..49cfbd83b 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -18,6 +18,7 @@ import { canUseChannel } from '../../services/security/channelAccess.js'; import * as channelStorage from '../../services/storage/configuredAdapterStorage.js'; import { canAccessJob, canModifyJob } from '../../services/security/access.js'; import { normalizeCommuteFilter } from '../../utils/commuteBudget.js'; +import { jobCurrencyProblem } from '../../services/providers/currency.js'; import { providerListProblem } from '../../services/jobs/providerUrl.js'; import { getProviders } from '../../utils.js'; @@ -244,7 +245,8 @@ export default async function jobPlugin(fastify) { const providerMeta = new Map( (await getProviders()).map((module) => [module?.metaInformation?.id, module?.metaInformation]), ); - const providerProblem = providerListProblem(provider, providerMeta); + const providerProblem = + providerListProblem(provider, providerMeta) ?? jobCurrencyProblem(provider, providerMeta, specFilter); if (providerProblem != null) { return reply.code(400).send({ error: providerProblem }); } diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 868907d4b..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 772e99151..a709ebda8 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'; /** @@ -340,6 +341,12 @@ export function applyDraftUpdate(draft, patch, ctx) { next.enabled = patch.enabled === true; } + const currencyProblem = jobCurrencyProblem( + next.providers, + new Map(ctx.providers.map((p) => [p.id, p])), + next.specFilter, + ); + if (currencyProblem) problems.push(currencyProblem); if (problems.length > 0) return { ok: false, draft, problems }; if (touchedRefinement) next.refinementsAnswered = true; @@ -402,7 +409,12 @@ export function describeDraft(draft, ctx) { spec == null ? '–' : [ - spec.maxPrice != null ? `max ${spec.maxPrice} EUR` : null, + spec.maxPrice != null + ? `max ${spec.maxPrice} ${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, ] @@ -478,7 +490,7 @@ export function nextStep(draft, ctx) { return { key: 'refinements', question: - 'Optional refinements - answer with any of these, or say "skip": maximum price in EUR, minimum size in m², ' + + 'Optional refinements - answer with any of these, or say "skip": maximum price in the selected portals’ currency, minimum size in m², ' + 'minimum number of rooms, words that should exclude a listing, ' + (addresses.length > 0 ? `travel-time limits in minutes for your saved addresses (${addresses.join(', ')}), ` diff --git a/lib/mcp/mcpAdapter.js b/lib/mcp/mcpAdapter.js index d21cf17bd..a5af90d75 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'); } @@ -750,7 +776,12 @@ export function createMcpServer() { .describe('Words that exclude a listing when they appear in it. The full list; an empty list clears it.'), specFilter: z .object({ - maxPrice: z.number().positive().nullable().optional().describe('Maximum price in EUR'), + maxPrice: z + .number() + .positive() + .nullable() + .optional() + .describe('Maximum price in the selected providers’ currency; providers must share one currency'), minSize: z.number().positive().nullable().optional().describe('Minimum size in m²'), minRooms: z.number().positive().nullable().optional().describe('Minimum number of rooms'), }) diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js index 40e5c627b..346aed61a 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -192,7 +192,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) { for (const l of listings) { // Listed is the date the portal itself states, which is the order the web UI reads in; Found // is when Fredy first stored the row. They differ whenever a flat is discovered late. - md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${dealTypeLabel(l.dealType)} | ${cell(l.size)} | ${cell(l.provider)} | ${cell(internetCell(l))} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${l.published_at != null ? formatDate(l.published_at) : '–'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; + md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)}${l.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 { @@ -215,7 +215,7 @@ export function normalizeGetListing(listing) { md += `- **Title:** ${listing.title || '–'}\n`; md += `- **Description:** ${listing.description || '–'}\n`; md += `- **Address:** ${listing.address || '–'}\n`; - md += `- **Price:** ${listing.price ?? '–'}${listing.dealType === 'rent' ? ' (monthly rent)' : ''}\n`; + md += `- **Price:** ${listing.price ?? '–'}${listing.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 610d006be..2b8175484 100644 Binary files a/lib/services/listings/priceHistoryService.js and b/lib/services/listings/priceHistoryService.js differ 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 9af05b543..028a8614e 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}) @@ -804,7 +813,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, @@ -813,7 +822,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 @@ -926,13 +935,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 @@ -1000,6 +1009,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 @@ -1031,12 +1041,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`, ); @@ -1049,6 +1059,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, @@ -1120,7 +1133,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) => { @@ -1135,7 +1148,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; } @@ -1234,6 +1251,7 @@ function buildListingWhere({ jobNameFilter, jobIdFilter, providerFilter, + currencyFilter = null, watchListFilter, statusFilter, freeTextFilter, @@ -1286,6 +1304,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)'); @@ -1314,11 +1337,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)'); } @@ -1336,6 +1359,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]; @@ -1461,6 +1486,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. * @@ -1471,6 +1537,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'. @@ -1496,6 +1563,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, @@ -1504,6 +1572,7 @@ export const queryListings = ({ jobNameFilter, jobIdFilter, providerFilter, + currencyFilter = null, watchListFilter, statusFilter, freeTextFilter, @@ -1527,11 +1596,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, @@ -1547,7 +1617,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 @@ -1597,7 +1669,11 @@ export const queryListings = ({ params, ); - return { totalNumber, page: safePage, result: attachTravelTimes(rows.map(parseListingStatus)) }; + return { + totalNumber, + page: safePage, + result: attachTravelTimes(rows.map(parseListingStatus)), + }; }; /** @@ -1736,8 +1812,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} @@ -1940,6 +2020,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/54.listing-currency.js b/lib/services/storage/migrations/sql/54.listing-currency.js new file mode 100644 index 000000000..d7eb45edb --- /dev/null +++ b/lib/services/storage/migrations/sql/54.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 a9aca293f..4bab4f85d 100644 --- a/test/pipeline_priceChange.test.js +++ b/test/pipeline_priceChange.test.js @@ -75,6 +75,7 @@ afterEach(() => db.close()); const advert = (hash, price, overrides = {}) => ({ id: hash, price, + currency: 'EUR', size: 60, rooms: 2, title: 'Flat', @@ -101,6 +102,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..a94c1dc1b --- /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/54.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 54 - 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 d37759f1f..24151d66e 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'; @@ -396,10 +398,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, specFilter, spatialFilter, commuteFilter }, - { t, formatPrice: (value) => formatEuro(value, locale) }, + { t, formatPrice: (value) => formatEuro(value, locale, currency ?? 'EUR') }, ); const handleProviderEdit = (data) => { @@ -744,10 +758,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