Repository navigation
Conversation
b5aac38 to
5e26630
Compare
5e26630 to
de7844b
Compare
orangecoding
left a comment
There was a problem hiding this comment.
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 €".
| const sameProvider = currencyContext.resolve({ ...listing, provider }) !== 'EUR'; | ||
| const runQuery = (sql, params) => | ||
| SqliteConnection.query( | ||
| sql.replace( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done, computeBenchmark takes the scope as an explicit condition + params argument now, no more rewriting the SQL.
| import { storeListingGallery } from './listingMediaStorage.js'; | ||
| import { DEFAULT_CURRENCY } from '../providers/currency.js'; | ||
|
|
||
| const DEFAULT_CURRENCY_CONTEXT = { resolve: () => DEFAULT_CURRENCY, euroSql: '1 = 1' }; |
There was a problem hiding this comment.
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.
| 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)']; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed, I made it pure again by moving check to assertPriceBoundHasOneCurrency 👍
| const mobileMask = mobileTech ? filterMask(mobileTech, mobileOperator ?? null) : null; | ||
|
|
||
| const currencyContext = await getCurrencyContext(); | ||
| const result = queryListings({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| {kpis.medianPricePerSqm == null ? '---' : formatPricePerSqm(kpis.medianPricePerSqm.value, locale, false)} | ||
| {kpis.medianPricePerSqm == null | ||
| ? '---' | ||
| : formatPricePerSqm(kpis.medianPricePerSqm.value, locale, false, kpis.currency ?? 'EUR')} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Both tiles now say "Not shown: your listings span several currencies"
| for (const listing of listings) { | ||
| const provider = | ||
| listing.provider ?? | ||
| SqliteConnection.query('SELECT provider FROM listings WHERE id = ?', [listing.id])[0]?.provider; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}/%`)}`, |
There was a problem hiding this comment.
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.
| return normalizeError('Listing not found or access denied.', 'get_listing'); | ||
| } | ||
|
|
||
| listing.currency = (await listingCurrencyResolver())(listing); |
There was a problem hiding this comment.
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.
|
|
||
| for (const listing of listings) { | ||
| if (currencyContext.resolve(listing) !== 'EUR') { | ||
| skipped.unsupportedCurrency++; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed by filtering query in currencyFilter and now non-EUR cant use up the cap
| ) : ( | ||
| <div className="map-view-container__price-slider"> | ||
| <div className="map__rangesliderLabels"> | ||
| <span>{formatListingPrice(priceRange[0], locale, currency)}</span> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Thanks @orangecoding for the thorough review! I addressed all of the comments and retested changes in the browser |
|
@gajewsky this is ok to get merged now. Can you fix the merge conflicts before? |
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:
list_listingsgets acurrencyargument.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:checkandyarn build:frontendall pass.Notes for review:
priceHistoryService.jsshows as binary because of a literal NUL byte. This replaces it with\x1f, so later diffs are readable.Related issue
none
AI disclosure (required)
ai:noneai:assistedai: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:offlinepassesyarn lintandyarn format:checkpass