Skip to content

feat: resolve listing currencies from provider metadata - #500

Open
gajewsky wants to merge 2 commits into
orangecoding:masterfrom
gajewsky:feat/provider-currency
Open

gajewsky wants to merge 2 commits into
orangecoding:masterfrom
gajewsky:feat/provider-currency

Conversation

@gajewsky

@gajewsky gajewsky commented Oct 28, 2026 •

Copy link
Copy Markdown

What does this PR do?

Providers can now declare the currency they price in, and each listing stores it. Flatfox and betterhomes.ch are CHF, and everything else stays EUR. Fredy doesn't convert anything, it just shows the real currency instead of always €.

Prices in different currencies no longer get compared:

  • The dashboard hides its medians for a mix, and says why.
  • Benchmarks only use listings in the same currency.
  • A job price ceiling needs sources in one currency.
  • The map slider and MCP price filters need one currency. list_listings gets a currency argument.
  • Finance and price tracking stay EUR only.

Migration 54 adds listings.currency, backfills flatfox and betterhomes.ch, and re-measures benchmarks that could have mixed currencies near a border.

Needed for Otodom (#501). There's no Otodom code here.

Validation

yarn test:offline (5,065 passed, 33 skipped), yarn lint, yarn format:check and yarn build:frontend all pass.

Notes for review:

  • The migration is 54 because master already has a 53.
  • priceHistoryService.js shows as binary because of a literal NUL byte. This replaces it with \x1f, so later diffs are readable.
  • Unrelated one-liner: a debug bundle test now only checks the Hostname field, because the hostname can also appear in the OS release string.

Related issue

none

AI disclosure (required)

  • ai:none
  • ai:assisted
  • ai:generated - AI produced the implementation, tests, documentation and commit message. I reviewed it line by line, asked agents to address all my findings and then tested feature manually in the browser.

Which AI: Codex in Pi configured model gpt-6.1-sol.
How much: Most of the implementation, tests, documentation, PR text and commit message are AI-generated.
Why: Implement the contribution guide's provider-currency design as the prerequisite for Polish Otodom support, with regression coverage across existing price consumers.

Checklist

  • yarn test:offline passes
  • yarn lint and yarn format:check pass
  • The change is useful for everybody, not a custom tweak for my own setup
  • I have read and answered the AI disclosure above honestly

@gajewsky
gajewsky force-pushed the feat/provider-currency branch from 5e26630 to de7844b Compare October 28, 2026 13:39

@orangecoding orangecoding left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope is right and the tests are good, but a few things need to change first.

The main one: currency is resolved at read time, but unlike countries it has to end up in SQL (benchmarks, price filter, affordability filter, KPIs). That's where the generated LIKE-per-host SQL comes from, along with the string rewrite of the benchmark query, a context object threaded through a dozen storage signatures, and an all-EUR default for anyone who forgets to pass it. I'd rather store currency on the listing row at insert (storeListings knows provider and link), with one migration that backfills flatfox and betterhomes.ch. Most of providerCurrency.js goes away then, and every check becomes l.currency = ?.

Also missing: lib/services/application/placeholders.js still formats {{listing.price}} and {{listing.pricePerSqm}} with a hardcoded EUR, so an application letter for a Flatfox listing says "1.440 €".

Comment thread lib/services/storage/listingsStorage.js Outdated
const sameProvider = currencyContext.resolve({ ...listing, provider }) !== 'EUR';
const runQuery = (sql, params) =>
SqliteConnection.query(
sql.replace(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patching COMPARABLES_SQL with a string replace is fragile. marketBenchmark.js now needs a comment warning that rewording one WHERE clause silently mixes francs and euros again. Please make the scope an explicit input of computeBenchmark (an extra condition plus params) instead of rewriting another module's query text.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, computeBenchmark takes the scope as an explicit condition + params argument now, no more rewriting the SQL.

Comment thread lib/services/storage/listingsStorage.js Outdated
import { storeListingGallery } from './listingMediaStorage.js';
import { DEFAULT_CURRENCY } from '../providers/currency.js';

const DEFAULT_CURRENCY_CONTEXT = { resolve: () => DEFAULT_CURRENCY, euroSql: '1 = 1' };

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This default treats every listing as EUR and applies no currency filter at all. Any caller that forgets the context silently gets francs as euros, which is the opposite of "refuse rather than guess". Tracker.trackJobPriceObservation already calls getListingsKpisForJobIds without one. Make it required, or let storage resolve it itself.

Comment thread lib/services/storage/listingsStorage.js Outdated
whereParts.push('(l.created_at <= @createdBefore)');
}
// Check the unpriced scope so a bound cannot silently hide another currency.
const priceScope = [...whereParts, hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)'];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildListingWhere was a pure builder. Now it runs a query and throws a 400 from the storage layer. It also brings back a second copy of the FROM (INNER JOIN on jobs where LISTING_FILTER_FROM_SQL uses LEFT JOIN) and of the manually_deleted clause, which is exactly what the doc comment above warns about. Please reuse LISTING_FILTER_FROM_SQL, do the check in queryListings/deleteListingsByFilter, and let the router pick the status code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I made it pure again by moving check to assertPriceBoundHasOneCurrency 👍

Comment thread lib/mcp/mcpAdapter.js Outdated
const mobileMask = mobileTech ? filterMask(mobileTech, mobileOperator ?? null) : null;

const currencyContext = await getCurrencyContext();
const result = queryListings({

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a user with jobs in two currencies, list_listings with minPrice/maxPrice now throws "Select one currency before filtering by price.". This tool has no currency parameter, so the model can't act on that, and it comes back as a thrown error instead of going through normalizeError. Catch it here and tell the model to narrow by jobId or provider.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I addressed both:

  • mixed-currency case is caught and returned through normalizeError
  • added currency param to the tool

The currency param occured to be required as for example betterhomes is one
provider with both EUR and CHF hosts

Comment thread ui/src/views/dashboard/Dashboard.jsx Outdated
{kpis.medianPricePerSqm == null ? '---' : formatPricePerSqm(kpis.medianPricePerSqm.value, locale, false)}
{kpis.medianPricePerSqm == null
? '---'
: formatPricePerSqm(kpis.medianPricePerSqm.value, locale, false, kpis.currency ?? 'EUR')}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With mixed currencies medianPricePerSqm is null, so the tile falls through to kpiMedianSqmPending and says it's waiting for listings with a size. That's not why. The median price tile just shows --- with no hint either. Mixed currencies need their own text.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both tiles now say "Not shown: your listings span several currencies"

Comment thread lib/services/storage/listingsStorage.js Outdated
for (const listing of listings) {
const provider =
listing.provider ??
SqliteConnection.query('SELECT provider FROM listings WHERE id = ?', [listing.id])[0]?.provider;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pipeline now mutates listings to carry provider just so this per-listing SELECT provider fallback doesn't fire. A run has exactly one provider, so pass this._providerId in and drop the fallback query. It's an N+1 no caller needs anymore.

@gajewsky gajewsky Nov 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - applyMarketBenchmark scopes on l.currency = @Currency and never needs the provider

const euroLinks = hosts
.filter((host) => currencyForListing(meta, { link: `https://${host}/` }) === 'EUR')
.flatMap((host) => [
`l.link LIKE ${quote(`https://${host}/%`)}`,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only works if currencyOf depends on nothing but the host: this SQL, currencyByHost in providerRouter and the lookup in JobMutation all probe it with https://${host}/. A currencyOf that reads anything else gives JS and SQL that disagree, with no error. If this stays read-time, make it declarative (a host to currency map in metaInformation) so all three read the same data. And please use bind params instead of hand-quoted SQL.

Comment thread lib/mcp/mcpAdapter.js Outdated
return normalizeError('Listing not found or access denied.', 'get_listing');
}

listing.currency = (await listingCurrencyResolver())(listing);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two ways to attach currency now: getListingById(..., { currencyContext }) in the router, and mutating the result here. Pick one. getCurrencyForListing in providerCurrency.js is unused.

Comment thread lib/api/routes/financeRouter.js Outdated

for (const listing of listings) {
if (currencyContext.resolve(listing) !== 'EUR') {
skipped.unsupportedCurrency++;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-EUR rows still count against MAX_SCORED_LISTINGS and get thrown away afterwards, so with enough CHF listings some EUR ones past the cap are never scored. Filter them in the query.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by filtering query in currencyFilter and now non-EUR cant use up the cap

Comment thread ui/src/views/listings/Map.jsx Outdated
) : (
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{formatListingPrice(priceRange[0], locale, currency)}</span>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loses the compact format: a buy job now shows "1.250.000 €" under the slider instead of "1,3 Mio. €". Give formatEuroCompact a currency parameter instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed formatEuroCompact to take a currency now. CHF job shows "CHF 2.7K" on the slider

Currency was resolved at read time, but it has to reach SQL for the
benchmarks, the price and affordability filters and the dashboard KPIs.
That took generated per-host SQL, a text rewrite of the benchmark query,
a context object threaded through storage, and an all-EUR default for
any caller that forgot it.

The pipeline now writes listings.currency at insert, and migration 54
backfills flatfox and betterhomes.ch. Every check becomes a comparison
on that column. A row without a currency fails on the NOT NULL column
rather than defaulting to euros.

Near a border a stored benchmark could have blended two currencies, so
the migration measures those listings again, along with the scam signal
that depends on them. Everything else keeps the benchmark it was found
with.

- Declare per-host exceptions as data (currencyByHost), replacing
  currencyOf
- Pass the benchmark scope in explicitly, which also keeps migration 41
  working on a fresh install
- Filter the finance sweep by currency in the query, so other currencies
  cannot use up its cap
- Check price bounds outside buildListingWhere and let routes and MCP
  choose the answer; add a currency argument to list_listings
- Say why dashboard medians are hidden for mixed currencies
- Price application letters in the listing's currency
- Skip price observations outside the euro
- Keep the compact and rounded price formats in other currencies
- Do not offer the finance calculator on non-EUR listings
- Ignore unpriced listings when deciding a map selection is mixed, and
  do not count a price bound the map is not applying
- Remove formatEuroPrice, which nothing calls any more
@gajewsky

gajewsky commented Nov 2, 2026 •

Copy link
Copy Markdown
Author

Scope is right and the tests are good, but a few things need to change first.

The main one: currency is resolved at read time, but unlike countries it has to end up in SQL (benchmarks, price filter, affordability filter, KPIs). That's where the generated LIKE-per-host SQL comes from, along with the string rewrite of the benchmark query, a context object threaded through a dozen storage signatures, and an all-EUR default for anyone who forgets to pass it. I'd rather store currency on the listing row at insert (storeListings knows provider and link), with one migration that backfills flatfox and betterhomes.ch. Most of providerCurrency.js goes away then, and every check becomes l.currency = ?.

Also missing: lib/services/application/placeholders.js still formats {{listing.price}} and {{listing.pricePerSqm}} with a hardcoded EUR, so an application letter for a Flatfox listing says "1.440 €".

Thanks @orangecoding for the thorough review! I addressed all of the comments and retested changes in the browser

@gajewsky
gajewsky marked this pull request as ready for review November 2, 2026 18:12
@gajewsky
gajewsky requested a review from orangecoding November 2, 2026 18:13
@orangecoding

Copy link
Copy Markdown
Owner

@gajewsky this is ok to get merged now. Can you fix the merge conflicts before?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants