Skip to Content
FeaturesProducts & price trackingOverview

Products & price tracking

Per-user catalog of products with multi-source price observations (receipts, manual entry, web aggregator), per-store comparison, recent-vs-baseline trend computation, and watched-product refresh that fires PRICE_DROP notifications.

Overview

The Products module was extracted from Finance into its own feature so it can serve both Finance (receipt-driven price history) and Nutrition (recipe ingredient cost estimation), and so the web-search / watched-products subsystem stays decoupled from the receipt-OCR flow. Prices can enter from five paths, all writing to the same product_price_observations table:

SourceTriggerIdentifier on the row
Receipt scanFinance receipt confirm (OpenAI vision → preview → confirm)source = RECEIPT, receipt_line_item_id set
Manual entryPOST /products/observations (user-driven form)source = MANUAL, receipt_line_item_id = NULL
Web importPOST /products/web-import (user picks a result from web search)source = WEB_SEARCH, product_url set
Watched-product refresh (SerpApi)Server scheduler re-queries SerpApisource = WEB_SEARCH, product_url set, audit row provider = SERPAPI
URL re-scrapePOST /products/observations/{id}/refresh-url or scheduler’s per-watch URL passsource = WEB_SEARCH, product_url preserved, audit row provider = URL_SCRAPE

All five paths share the same product matching (per-user catalog keyed by normalized_key) so per-store comparison aggregates across receipts, SerpApi results, and direct URL scrapes equally. The two web-driven paths share the WEB_SEARCH enum value — they’re distinguished only on the audit table (web_product_search_runs.provider).

Capabilities

CapabilityEndpoint / surface
Browse user’s products with stats + trendGET /products (filter by productType, productCategory, q)
Catalog for pickers (no stats)GET /products/catalog
Detail with full observation historyGET /products/{id}
Per-store comparison (min/avg/last)GET /products/{id}/stores
Create a new product (no observation yet)POST /products
Toggle product type INGREDIENT ↔ GENERALPATCH /products/{id}
Set the fine-grained categoryPATCH /products/{id}/category
Add to / remove from wishlistPATCH /products/{id}/wishlist
List wishlist productsGET /products/wishlist
Manual price entryPOST /products/observations
One-shot purchase Transaction linked to a productPOST /finance/transactions with productId
Recurring purchase ScheduledTransaction linked to a productPOST /finance/scheduled-transactions with productId
Filter scheduled transactions to product-linked rowsGET /finance/scheduled-transactions?productOnly=true
Ad-hoc web price searchPOST /products/search
Import a web search resultPOST /products/web-import
Watched products CRUDGET / POST / PATCH / DELETE /products/watches/{id?}
Force a watched product to refresh nowPOST /products/watches/{id}/check-now
Re-scrape a single observation’s stored URLPOST /products/observations/{id}/refresh-url
Find listings similar to a product (same site or general web)POST /products/{id}/similar
Attach a candidate to a research-umbrella parentPATCH /products/{id}/parent
List candidate children of a parent productGET /products/{id}/children
Rename a productPATCH /products/{id}/name
Edit a product’s brand / category / typePATCH /products/{id} (type), PATCH /products/{id}/brand, PATCH /products/{id}/category
Soft-delete a productDELETE /products/{id}

Clients: Android, Desktop, Web (wasmJs), iOS — UI is shared from shared/.../oter/products/ui/. Receipt scan UI lives in Finance; everything else (list, detail, manual entry, web search, watched products) lives in the products module.

Requirements

  • PRODUCTS feature flag enabled for the platform — gates the main browser screens.
  • PRODUCTS_WEB_SEARCH feature flag — gates the web-search and watched-products screens. Independent so the web subsystem can be soft-disabled per platform.
  • OPENAI_API_KEY — only needed for the receipt-scan ingestion path (see Bills reader). Manual / web paths work without it.
  • SERPAPI_KEY — needed for /products/search, /products/web-import (when initiated from a web search), /products/{id}/similar, and the watched-product scheduler’s SerpApi pass. When unset, those routes return 503 and the scheduler short-circuits each tick. Not required for /products/observations/{id}/refresh-url or the scheduler’s URL re-scrape pass — those use ProductUrlScrapeService directly.
  • SERPAPI_DAILY_BUDGET (optional, default 20) — caps autopilot scheduler SerpApi calls per rolling 24h window. Ad-hoc /products/search, /products/{id}/similar, and URL re-scrape are not budget-checked.
  • Jsoup 1.18.1 (server-only dependency, declared in gradle/libs.versions.tomllibs.jsoup) — HTML parser used by ProductUrlScrapeService. Free / unmetered. No env config needed.

How prices flow in

Data model

Unique constraints: (user_id, normalized_key) on products; (user_id, product_id) on watches.

product_category is a single-string column matching the ProductCategory enum — 22 values + null. Nullable on every product; pre-V34 rows stay null until classified by the user or by the heuristic in inferCategoryFromName. The dropdown in the UI restricts the visible categories by the product’s product_type (see Category dropdown filtering).

wishlist_added_at is a single nullable timestamp. Non-null = on wishlist; the value is the most recent toggle-on time and drives the Planned-purchases sort order. Indexed via a partial index WHERE wishlist_added_at IS NOT NULL to keep “list wishlist” cheap.

transactions.product_id and scheduled_transactions.product_id are nullable FKs added in V36 with ON DELETE SET NULL — deleting a product orphans the link but preserves the spend history. The Finance side knows nothing about Products beyond this column.

parent_product_id (V48) is a nullable self-reference. When non-null, this product is a candidate under another product treated as a research umbrella (e.g., “Headphones I’m considering”). ON DELETE SET NULL so removing the parent drops the candidates back to standalone instead of cascade-deleting them. Single-level invariant is enforced server-side in setParent — a candidate cannot itself be a parent, and a product that already has children cannot become a candidate. See Research umbrellas.

A separate web_product_search_runs audit table records every outbound aggregator call (success or failure). The scheduler reads it to enforce the daily budget. The provider column distinguishes SERPAPI (paid Google Shopping pass) from URL_SCRAPE (free direct page fetches via ProductUrlScrapeService).

Product category taxonomy

ProductCategory (22 values + null) lives next to ProductType (INGREDIENT / GENERAL) and is finer-grained: where productType answers “is this used in recipe cost estimation?”, productCategory answers “what shelf does this live on?”.

BucketValues
Food / pantryDAIRY, PRODUCE, MEAT_SEAFOOD, PANTRY, BAKERY, FROZEN
Shared (food or not)BEVERAGES, SNACKS
CareHOUSEHOLD, PERSONAL_CARE, HEALTH, BABY, PET_SUPPLIES
Things & gearCLOTHING, ELECTRONICS, HOME_KITCHEN, BOOKS_MEDIA, OFFICE_SUPPLIES
ActivityFITNESS, HOBBIES, DIGITAL
Catch-allOTHER

DIGITAL is for one-off purchases (a Steam game, a paid app, an ebook). Subscriptions (Spotify, Netflix, gym membership) are deliberately not products — they’re recurring services without a unit price or store, so they live in Finance as a ScheduledTransaction with Category.SUBSCRIPTION or ENTERTAINMENT.

Category dropdown filtering by type

When the user picks productType = INGREDIENT in the Add-product / category-edit dialog, the category dropdown only shows food-applicable buckets (DAIRY, PRODUCE, etc., plus the shared BEVERAGES/SNACKS/FROZEN/OTHER). When they pick GENERAL, the dropdown shows household/things/gear/activity buckets plus the same shared set. If the user flips type and the current category no longer applies, the dialog auto-clears the category to null.

Defined in shared/.../oter/products/ui/components/ProductAtoms.kt → categoriesFor(type).

Auto-classification heuristic

inferCategoryFromName(name: String): ProductCategory? (same file) returns a best-guess category from a product’s canonical name using Spanish + English token matching: leche/milk → DAIRY, aceite/oil → PANTRY, cargador/charger → ELECTRONICS, mancuerna/dumbbell → FITNESS, guitarra/guitar → HOBBIES, steam/ebook → DIGITAL, etc. Returns null when no token matches — never force-bucket into OTHER. Reused by:

  • Receipt ingestion when the parsed name has a clear category (avoids requiring the user to classify everything by hand).
  • Client-side glyph picker (glyphForProduct) for visual consistency.

Default Finance category mapping

When the user opens Log purchase or Schedule purchase on a product, the Finance category picker pre-selects based on the product’s productCategory:

Product categoriesDefault Finance Category
DAIRY, PRODUCE, MEAT_SEAFOOD, PANTRY, BAKERY, FROZEN, BEVERAGES, SNACKSGROCERIES
HEALTH, PERSONAL_CARE, FITNESSHEALTH
HOUSEHOLDBILLS
HOBBIES, DIGITALENTERTAINMENT
Everything else (ELECTRONICS, CLOTHING, …, OTHER, null)SHOPPING

User can override before submit. Pure mapping function: defaultFinanceCategoryFor(category: ProductCategory?) in ProductAtoms.kt.

Trend computation

Each ProductPriceSummary now carries a trend: TrendDirection (UP | FLAT | DOWN) and a signed trendChangePercent: Double?. The math lives in a pure-Kotlin commonMain calculator so it’s testable without a DB.

SettingValueRationale
Recent windownow - 14d to nowMost recent prices, large enough to smooth weekly variation
Prior baseline windownow - 30d to now - 14d16-day comparison band that excludes the recent window
Threshold±5%Below: FLAT
Currency handlingFilter to the currency of the most-recent observationAvoids mixing USD/COP in the average
Insufficient dataFLAT, trendChangePercent = nullReturned when either window is empty or priorAvg == 0

The user-selected lookback (30 / 90 / 180 / all) controls only what is displayed. The trend window is fixed at 30 days regardless — the lookback never disables trend computation.

Code: shared/.../oter/products/TrendCalculator.kt.

Server-side search (q param)

GET /products?q=… filters by canonical_name and normalized_key (field.lowerCase() like "%q%"). When q is non-blank, products without observations in the lookback window are still returned so search feels honest. When q is null/blank, the existing behavior is preserved (only products with observations in window).

Client side, ProductPricesViewModel.setSearchQuery(q) debounces 300ms before firing loadProducts(q=…). The previous client-side filter on a pre-loaded list has been removed.

Web product search (SerpApi)

POST /products/search proxies to SerpApi’s google_shopping engine with gl=co&hl=es for Colombian shopping coverage. Google Shopping Colombia indexes Éxito, Carulla, Olímpica, Falabella, MercadoLibre Colombia, and Jumbo (Cencosud) regularly. Known coverage gap: hard-discount chains D1, Ara, Justo & Bueno do not advertise to Google Shopping — they are not visible to any aggregator. Surface “log manually” guidance for those.

Currency for gl=co is hardcoded to COP (no parsing of locale-formatted price strings). Result URLs are validated server-side to begin with http:// or https:// before being stored.

Daily budget

The scheduler enforces a soft daily call cap:

remainingBudget = SERPAPI_DAILY_BUDGET − count(web_product_search_runs.ran_at >= now − 24h)

If remainingBudget <= 0 the tick is skipped. Ad-hoc /products/search calls are not budget-checked — they’re user-initiated and rare in practice.

Watched products + scheduler

POST /products/watches creates a watch. intervalHours is validated to 6..168. The scheduler (ProductPriceWatchRefreshScheduler) runs once every 24 hours on the same coroutine-loop pattern as ScheduledTransactionCheckerService. The loop cadence matches the natural per-watch interval default (24 h) so we don’t burn iterations checking watches that aren’t due — a single tick processes every due watch in one pass. Per tick:

  1. Daily budget check (see above).
  2. Find due watches (last_checked_at IS NULL OR last_checked_at + interval_hours <= now), sorted nulls-first, capped at remainingBudget.
  3. For each watch:
    • SerpApi pass — call WebProductSearchService.search(query), filter by stores_filter, take the lowest-price hit. Records one web_product_search_runs row with provider = SERPAPI.
    • URL re-scrape passproductPriceService.listObservationsWithUrls(...) returns recent observations with a non-null productUrl (deduped by URL, capped at 5). Each URL is fetched via ProductUrlScrapeService and, when a price is parseable, a fresh WEB_SEARCH observation is recorded. Each scrape is audited as a separate web_product_search_runs row with provider = URL_SCRAPE. URL scrapes do not count against the SerpApi budget but they do increment the run table for visibility.
  4. Suppression rules (SerpApi pass only) — insert an observation only when:
    • last_known_unit_price is null, OR
    • last_checked_at is null, OR
    • |new − last| / last >= 1%, OR
    • daysSince(last_checked_at) >= 7. last_checked_at always advances even when the insert is skipped, so the scheduler does not re-hammer the aggregator. The URL pass does not apply these rules — every successful scrape inserts a fresh observation, since direct fetches are cheap and we want a denser per-URL history.
  5. If notify_below_price != null && newPrice <= notify_below_price, dispatch a NotificationType.PRICE_DROP (not throttled — drops are already gated by the rules above). Both passes can fire this notification.
  6. Always write a row to web_product_search_runs (success or failure) so the next tick’s budget accounting includes the SerpApi call. URL scrapes also write rows here for parity, though only the SerpApi rows count toward the budget.

A user can also force an off-schedule refresh via POST /products/watches/{id}/check-now which calls into the same scheduler entry point (SerpApi pass only — the URL pass runs as part of the regular refreshOne flow).

URL price refresh

A stored productUrl is also treated as a live price source — independent of (and free of) the SerpApi budget. Two surfaces use it:

  • On-demandPOST /products/observations/{id}/refresh-url re-fetches the observation’s URL, parses a fresh price, and records a new WEB_SEARCH observation against the same product. Returns RefreshUrlPriceResponse carrying both the updated product summary and the scrape snapshot (title, unitPrice, currency, storeName, availability) so the client can show a “found X at $Y” toast even when the recorded price matched the previous one. 422 when the page has no parseable price; 400 when the observation has no URL; 502 on transport failure.
  • In the scheduler — see step 3 of the watch tick above.

Parsing layers

ProductUrlScrapeService (Jsoup) tries three layouts in order, falling back when each yields no price:

  1. JSON-LD schema.org/Product — most reliable signal, present on most modern e-commerce pages. Walks <script type="application/ld+json"> blocks, including @graph arrays, and pulls offers.price / offers.priceCurrency / offers.availability.
  2. Open Graph / Twitter meta tagsproduct:price:amount, og:price:amount, product:sale_price:amount and the matching *:currency siblings. Used by Shopify, MercadoLibre, and many Latin American storefronts.
  3. Microdata[itemprop=price], [itemprop=priceCurrency], [itemprop=availability]. Older sites and some long-tail vendors.

Store name is derived from the URL’s host (exito.com → “Éxito”, normalized via StoreNameNormalizer). Price strings like "1.299.000", "$ 1,299,000.00", or "1299,00 €" are parsed locale-tolerantly: the last comma-or-dot is treated as the decimal separator unless the trailing group is exactly 3 digits (thousands separator).

Safety / hardening

  • Only http:// and https:// URLs are accepted (ProductPriceService already enforces this on storage; the scraper checks again at fetch time).
  • 10-second request / connect / socket timeout.
  • 2 MB response-size cap (MAX_HTML_CHARS = 2_000_000).
  • One automatic retry on transient 5xx with exponential backoff.
  • Polite User-Agent: Mozilla/5.0 (compatible; LifeCommanderBot/1.0; +https://lifecommander.app/bot).
  • No browser execution, no JS evaluation — by design. Sites that gate prices behind client-side JS (Amazon, etc.) will need per-site adapters or a headless-browser tier; deferred.

Coverage caveats

JSON-LD covers MercadoLibre, Éxito, Carulla, Amazon, AliExpress, most Shopify storefronts, and most Latin American chains. It does not cover sites that render prices entirely from client-side JS (some single-page-app storefronts) or that block bots at the CDN edge. When a scrape returns null price, the on-demand endpoint surfaces 422 so the user knows to fall back to manual entry; the scheduler quietly skips the URL but still audits the run.

Similar product discovery

POST /products/{id}/similar returns SerpApi WebSearchResultItem[] for listings comparable to the target product. The request body picks the scope:

{ "scope": "SAME_SITE", "referenceUrl": null } // default { "scope": "GENERAL", "referenceUrl": null } // general web — same as /products/search but auto-prefilled { "scope": "SAME_SITE", "referenceUrl": "https://exito.com/some-product-page" } // explicit reference

SAME_SITE derives the SerpApi site:<host> filter from the URL’s host. When referenceUrl is omitted the server picks the most-recent observation’s productUrl; if there is none, the call returns 400. GENERAL runs an un-scoped search (equivalent to today’s /products/search with the product’s canonical name as query).

On the client, the detail screen exposes this via the “Similar on this site” action — only visible when the product has at least one observation with a URL. The action navigates to WebProductSearchRoute with similarMode = SAME_SITE; on launch the screen calls searchSimilar(productId, SAME_SITE) instead of the regular searchWeb(), so the existing import flow (POST /products/web-import) works unchanged.

WebProductSearchService.search() accepts an optional siteFilter: String? — when present, it normalizes the host (strips scheme + www. + trailing path) and appends site:<host> to the SerpApi query. The same gl=co&hl=es defaults apply.

Research umbrellas (parent vs. specific products)

A user shopping for a category — “I want one of these headphones” — often needs to compare several specific candidates before deciding. The catalog supports this directly through a self-reference on the products table: parent_product_id (V48, nullable, ON DELETE SET NULL). There are three product shapes:

ShapeWhat it meansHow to recognizeWishlist semantics
Standalone”Just track this one item”parent_product_id IS NULL and no children point at itwishlist_added_at set = “I want this exact item”
Research umbrella (parent)“I’m comparing options”parent_product_id IS NULL and ≥1 child points at itwishlist_added_at set = “I want one of these”
Candidate (child)“An option I’m considering”parent_product_id IS NOT NULLUsually not on the wishlist directly — the parent carries the intent

The hierarchy is intentionally single-level. ProductPriceService.setParent rejects any assignment that would create grandparents or grandchildren — the parent itself must be standalone, and the prospective child must have no children of its own. Self-references are also rejected. The single-level rule keeps the comparison UX simple (one parent’s grid lists exactly the candidates being weighed) and avoids deep-tree rendering questions.

Children carry their own observations, watches, URLs, and price history. The parent’s own observations are typically empty — the parent is a conceptual grouper. listProducts skips the usual “no recent observations” exclusion for parents so they always appear in the list view.

ProductPriceSummary carries two new fields:

  • parentProductId: String? — non-null when this product is a child.
  • childrenCount: Int — number of active candidates pointing at this product. >0 means this is a parent.

The list-view card replaces the “N stores · M obs” subline with ”★ N candidates” when childrenCount > 0, so parents read at a glance.

ProductDetailResponse exposes the same two fields, and GET /products/{id}/children returns the candidates as a list of ProductPriceSummary sorted by (latestUnitPrice ascending, name ascending) — cheapest options first. The client auto-fires this fetch on loadProductDetail when the detail’s childrenCount > 0 so the comparison grid renders without a second user tap.

API

MethodPathBehavior
PATCH/products/{id}/parentBody: { "parentProductId": "<uuid>" | null }. null detaches the product back to standalone. 422 on self-reference, hierarchy violation, or cross-user parent.
GET/products/{id}/childrenQuery: lookbackDays=90. Returns ProductPriceSummary[] for active candidates only.

Client flow

ActionWhereWhat it does
Create a parentTop-bar + → Add product dialog → “Track as a research group” switchToggling the switch on collapses the form to just the umbrella name (brand / type / category are hidden — a parent has no meaningful single price, store, or category) and changes the dialog title to “Add research group”. On submit the row is created as productType = GENERAL and immediately shows up in the list with the ”★ candidates” placeholder. Toggling the switch off restores the standalone-product fields.
Add a candidateProduct detail → “Add to group” actionVisible only when the product is standalone (no children, no current parent). Opens ParentPickerDialog listing eligible parents (filtered to standalone products that aren’t the product itself).
Detach a candidateProduct detail → “Detach from group” actionVisible only when parentProductId != null. Calls PATCH .../parent with null.
Compare candidatesParent’s product detailRenders the Candidates strip under the action row — one card per child showing latest price, brand, trend chip, and cheapest store. Tap a card to drill into that candidate’s full detail.

Files: server/.../service/ProductPriceService.kt → setParent / listChildren / loadChildrenCounts, server/.../routing/ProductRouting.kt → PATCH /{id}/parent + GET /{id}/children, shared/.../oter/products/model/ProductModels.kt → SetParentProductRequest + new fields on summary/detail, shared/.../oter/products/ui/screens/ProductPricesScreen.kt → AddProductDialog research-group switch, CandidateChildCard, ParentPickerDialog, "Add to group" / "Detach from group" actions, V48__product_parent.sql.

Editing & deleting products

Products are edited and removed from their detail page — there is no inline edit / delete affordance on the list cards in v1. Open the list (sidebar → Shopping → Product prices, or mobile More → Shopping → Product prices), tap a row, and use the Edit / Delete buttons in the action row alongside Log purchase / Compare stores / Watch.

Edit

The Edit button opens EditProductDialog pre-filled with the current name, brand, type, and category. The form mirrors AddProductDialog’s layout (so users learn one pattern). On save, ProductPricesViewModel.editProduct per-field diffs the new values against the loaded ProductDetailResponse and fires only the PATCH calls that actually changed:

Field changedEndpoint
canonicalNamePATCH /products/{id}/name (V48) — also updates normalized_key so dedup keeps working
brandPATCH /products/{id}/brand
productTypePATCH /products/{id}
productCategoryPATCH /products/{id}/category

Name change runs first so a follow-up rename collision (server-side dedup on normalized_key) fails before we burn brand / type / category writes on a row that won’t persist. After all PATCHes the detail is re-fetched once so the UI sees consistent state across all four columns, even when no PATCH actually fires (a user submitting a no-op is treated as a refresh).

Category supports a separate clearCategory boolean because nullable category = null can’t disambiguate “leave as-is” from “explicitly clear”. The dialog fires clearCategory = true only when the user picked “None” and the original category was non-null.

Delete (soft)

The Delete button opens a confirm dialog that warns:

  • how many observations will be hidden (count from detail.observations);
  • when the product is a parent, how many candidates will be detached back to standalone.

On confirm, DELETE /products/{id} flips Products.status = INACTIVE in a single transaction and detaches any children pointing at this product (sets their parent_product_id = NULL). Observations are not touched — they stay status = ACTIVE, so a future undo / restore flow can flip the parent’s status back without losing price history. List queries already filter by Products.status = ACTIVE so the row disappears immediately from every surface (list, catalog picker, wishlist, planned purchases).

On success, the client pops the detail (the user lands back on the list) and removes the row from the local products state. Failures (404 on cross-user / already-deleted, 5xx on transport) surface via the screen-level snackbar.

Files: server/.../service/ProductPriceService.kt → updateProductName, deleteProduct, server/.../routing/ProductRouting.kt → PATCH /{id}/name, DELETE /{id}, shared/.../oter/products/model/ProductModels.kt → UpdateProductNameRequest, shared/.../oter/products/services/ProductPricesApi.kt → updateProductName, deleteProduct, shared/.../oter/products/ui/viewmodels/ProductPricesViewModel.kt → editProduct, deleteProduct, shared/.../oter/products/ui/screens/ProductPricesScreen.kt → EditProductDialog, Delete confirmation dialog, Edit / Delete buttons on the detail action row.

Products → Finance bridge

Each product can be the source of zero or more Finance Transactions (one-shot) and zero or more ScheduledTransactions (recurring). The link is a nullable product_id column on both Finance tables, added in V36. The Finance side is otherwise unchanged — it doesn’t know what a Product is beyond the FK.

Two entry points live on the product detail screen, alongside the existing Log price action:

ActionCreatesWhen to use
Log purchaseTransaction (one-shot, EXPENSE)“I bought this today” — records the spending against your account, separate from the price observation
ScheduleScheduledTransaction (recurring, EXPENSE)“I buy this weekly” — sets up a recurring reminder. Does not auto-post; apply_automatically stays false

Both open a dialog (desktop) or bottom sheet (mobile) — the same form, two presentations, picked at the call site via BoxWithConstraints against the DESKTOP_BREAKPOINT (900dp). Form fields are hoisted into LogPurchaseState / SchedulePurchaseState so the two presentations share zero chrome but render identical inputs.

Pre-fill rules:

  • Description = product’s canonicalName
  • Amount = product’s most recent observation’s unitPrice (null if no observations)
  • Account = first account in the user’s list (user can override)
  • Category = defaultFinanceCategoryFor(productCategory) (see mapping)
  • Date / Start date = today (dd/MM/yyyy HH:mm)
  • Frequency (Schedule only) = MONTHLY, interval 1
  • Type = always EXPENSE, hidden field

Server validates the supplied productId belongs to the calling user (Products.user_id = userId); cross-user IDs return 400 with IllegalArgumentException’s message (handled by the route).

Files: shared/.../oter/products/ui/screens/PurchaseDialogs.kt (form bodies + both presentations), shared/.../oter/products/ui/screens/ProductPricesScreen.kt (the three CTAs on the detail action row + dialog state), server/.../service/TransactionService.kt → createTransaction(..., productId), server/.../service/ScheduledTransactionService.kt → createScheduledTransaction(..., productId).

Wishlist + Planned purchases

A product can be marked as wanted but not yet bought. The wishlist itself is a single nullable wishlist_added_at timestamp on the products table — non-null = on wishlist. No separate table, no priority/notes (left as a deliberate v1 simplification).

Toggle flow:

  • Product detail action row exposes a ♡ Wishlist / ♥ On wishlist button.
  • Toggle hits PATCH /products/{id}/wishlist with { "added": true|false }; server stamps or clears the timestamp.
  • Client splices the updated ProductPriceSummary into both the products list and the catalog (optimistic, mirrors updateProductType).

The Planned purchases screen aggregates two states the user has explicitly planned around:

  1. ScheduledGET /finance/scheduled-transactions?productOnly=true returns only the rows with a non-null product_id. Sorted by startDate ascending (soonest first). Each row shows cadence (“every monthly cycle · starts 24/06/2026”), amount, and the Finance category. Tap → open product detail.
  2. WishlistGET /products/wishlist returns wishlist products sorted by wishlist_added_at desc with full observation stats so the row can show the last-known price. Trailing heart icon removes from wishlist.

The two sections render with the same hero band (PpHeroStat) and visual vocabulary as the rest of the products feature. Empty states are inline so the screen is useful even with one section populated.

LayerFile
Screenshared/.../oter/products/ui/screens/PlannedPurchasesScreen.kt
ViewModelshared/.../oter/products/ui/viewmodels/PlannedPurchasesViewModel.kt
Service shortcutFinanceScheduledTransactionService.getProductScheduledTransactions(limit)
RouteScreen.PlannedPurchases → composable at AppNavHost.kt, gated by PRODUCTS
Desktop sidebarAppMenuDefinition.kt — Shopping section, entry planned_purchases
Mobile More menuMoreScreens.kt — Shopping section, “Planned purchases” with heart icon

Buy-decision support

A reusable inline panel surfaced at the moments when the user is about to buy a product — currently on Planned Purchases scheduled rows (tap chevron to expand), inside the Log Purchase dialog (between Amount and Account, read-only there), and as the deep-link target of SCHEDULED_BUY_DUE notifications. The panel is built entirely from detail.observations — no new aggregation endpoint — so notes, store color, and observed-at age all flow through without extra plumbing.

ConcernHow it works
Per-store rowsdetail.observations grouped by storeName (case-insensitive), keep the latest entry per store, sort ascending by unitPrice. The cheapest row gets the existing BestTag lozenge.
Trade-off contextThe new obs.notes field renders as an italic line under the price (e.g. "500g, premium brand" vs "1kg generic").
Freshness”Latest data: 2d ago” line under the title uses the most recent observedAt across all sources. source is not yet exposed on ProductObservationEntry — surfacing it would let us distinguish “last web refresh” from “last manual log”; deferred.
RefreshPOST /products/{id}/refresh-prices — calls WebProductSearchService.search(canonicalName), takes the lowest-price hit per unique store, inserts one WEB_SEARCH observation per store. Mirrors the ad-hoc /products/search ergonomics: returns 503 if SerpApi is unconfigured, not budget-checked (user-initiated).
Buy hereWhen a store’s latest observation has a non-null productUrl, the row shows a “Buy here ↗” tap target that opens the URL via LocalUriHandler. Same URL safety as web-import: only http:// / https:// strings reach storage (validated server-side).
Per-row URL refreshEach row with a non-null productUrl and a known obs.id also shows a small affordance next to “Buy here ↗”. Tapping it fires refreshObservationUrl(observationId)POST /products/observations/{id}/refresh-url, which re-scrapes the page via ProductUrlScrapeService and inserts a fresh WEB_SEARCH observation. While the request is in flight, the row swaps the glyph for a small spinner. Free path — no SerpApi quota consumed.
Pre-fill (dialog only)onSelectStore callback parameter exists but is unused in v1 — the Log Purchase form has no store field, so the panel is read-only there. Promoted from the panel only when a future form needs it.

Notification path

A new NotificationType.SCHEDULED_BUY_DUE enum value fires when the existing ScheduledTransactionCheckerService finds a due scheduled tx with applyAutomatically = false and productId != null. The notification body advertises the cheapest known store (computed from the latest observation per store at dispatch time), and the data payload carries {scheduledTxId, productId, productName, cheapestStore, cheapestUnitPrice, currency}.

Dedup is enforced at dispatch time via the V41-added scheduled_buy_due_notifications table — composite PK on (user_id, scheduled_tx_id, occurrence_date). The scheduler does the insert first; a PK collision short-circuits the dispatch. Re-runs in the same minute / day are idempotent.

Notification tap → app routes to Screen.PlannedPurchases.route (where the panel inline-expands per row). The original plan called for auto-expanding the panel on the product detail screen; that’s a v2 enhancement — would require either threading a route argument or adding the panel to product detail.

What’s deliberately not in v1

Why
AI summarization / recommendationExplicit user choice — ship the data view first, layer LLM only if “show me the numbers” turns out to be insufficient.
Auto-refresh N days before scheduled dateExplicit user choice — refresh is user-triggered. The watch scheduler could opportunistically refresh before scheduled buys in v2.
Wishlist integrationSame panel would be useful on wishlist rows; deferred — only scheduled rows expand today.
Distance / travel-cost weightingWould need user address data the app doesn’t currently have.

Files: shared/.../oter/products/ui/components/BuyDecisionPanel.kt (the panel), shared/.../oter/products/ui/screens/PlannedPurchasesScreen.kt (chevron expansion + lazy detail load), shared/.../oter/products/ui/screens/PurchaseDialogs.kt (read-only insert), server/.../routing/ProductSearchRouting.kt (POST /products/{id}/refresh-prices), server/.../service/ScheduledTransactionCheckerService.kt (second per-user branch — checkScheduledBuyNotificationsForUser), server/.../database/entities/ScheduledBuyDueNotifications.kt, V41__scheduled_buy_due_notifications.sql.

API summary

All routes require authenticate { } (JWT). Base path is /api/v{VERSION}.

MethodPathNotes
GET/products?lookbackDays=90&productType=INGREDIENT&productCategory=DAIRY&q=…Trend fields populated; q includes products without recent observations
GET/products/catalog?productType=INGREDIENT&productCategory=DAIRY&includeStats=true&lookbackDays=90All active products. productType is optional — omit it to return all types (the price-logging picker omits it so GENERAL products show up too). The API interface default is still INGREDIENT for legacy ingredient-only callers like NewEditRecipeDialog.
GET/products/{id}Full observation history + wishlistAddedAt
POST/products{canonicalName, productType, productCategory?, brand?} — idempotent on normalized_key. The client’s Add dialog also exposes a “Track as a research group” switch that submits productType = GENERAL with the brand / category fields blank so the row reads as a parent umbrella.
PATCH/products/{id}{ "productType": "GENERAL" }
PATCH/products/{id}/name{ "canonicalName": "Sony WH-1000XM5" } — also rewrites normalized_key. 400 on blank, 404 on cross-user / not-found.
PATCH/products/{id}/brand{ "brand": "Sony" | null }
PATCH/products/{id}/category{ "productCategory": "DAIRY" | null }
PATCH/products/{id}/wishlist{ "added": true | false }
DELETE/products/{id}Soft delete (status = INACTIVE). Detaches any children pointing at this product back to standalone. Observations stay ACTIVE for restore. 204 on success, 404 on cross-user / already-deleted.
GET/products/wishlistWishlist products only, sorted by wishlist_added_at desc, with observation stats
GET/products/{id}/stores?lookbackDays=90Per-store min/avg/last
POST/products/observationsManual entry — {productId, storeName, unitPrice, quantity, currency, observedAt?, notes?, productUrl?}. notes is free-text trade-off context (rendered as italic subline on observation rows + in the BuyDecisionPanel). productUrl is optional and validated http(s) only — surfaces as a tap-to-open ”↗” on history rows and as “Buy here ↗” in the BuyDecisionPanel.
POST/products/{id}/refresh-pricesOn-demand SerpApi refresh. Body: {storesFilter:[]} (optional). Inserts one WEB_SEARCH observation per unique store (lowest price wins); returns the updated ProductDetailResponse. 503 when SerpApi is unconfigured. User-initiated → not budget-checked.
POST/products/observations/{id}/refresh-urlRe-scrape the URL stored on a single observation via ProductUrlScrapeService. Returns RefreshUrlPriceResponse (summary + scrape snapshot). 422 when no price is parseable; 400 when the observation has no URL. Free (no SerpApi).
POST/products/{id}/similarBody: {scope: "SAME_SITE"|"GENERAL", referenceUrl?: string}WebSearchResultItem[]. SAME_SITE derives site:<host> from referenceUrl or the product’s most-recent observation URL. 503 if SerpApi unconfigured; 400 if SAME_SITE has no reference URL.
POST/products/search{query, productId?, storesFilter:[]}WebSearchResultItem[]; 503 if SerpApi unconfigured
POST/products/web-import{productId, storeName, title, unitPrice, currency, productUrl?} → updated ProductPriceSummary
GET/products/watchesList the user’s active watches
POST/products/watches{productId, query, storesFilter:[], intervalHours:24, notifyBelowPrice?}
PATCH/products/watches/{id}Partial update; any null field leaves the existing value
DELETE/products/watches/{id}Soft delete (status = INACTIVE) — 204
POST/products/watches/{id}/check-nowForce off-schedule refresh; subject to budget
PATCH/products/{id}/parentBody: {parentProductId?: string|null}. Assigns or detaches a research-umbrella parent. 422 on cycle / cross-user / single-level violation.
GET/products/{id}/children?lookbackDays=90ProductPriceSummary[] for the parent’s active candidates, sorted by latest unit price ascending.
POST/finance/transactionsExisting route; now accepts productId? to link the transaction back to a product. Cross-user productId returns 400.
POST/finance/scheduled-transactionsExisting route; same productId? addition.
GET/finance/scheduled-transactions?productOnly=trueNew filter param; returns only rows with non-null product_id. Used by Planned purchases.

Shared DTOs (commonMain): shared/.../oter/products/model/ProductModels.kt, WebSearchModels.kt, TrendDirection.kt.

Validation: intervalHours in 6..168, unitPrice > 0, quantity > 0, storeName non-blank, productId parseable as UUID. Cross-user access returns 404 (not 403) to avoid leaking existence.

Notifications

Two notification types live in this feature, both throttleKey = null because each has its own dispatch-time gating that would be undermined by pipeline-level throttling.

PRICE_DROP

Fired by ProductPriceWatchRefreshScheduler when a watched product’s latest price drops below the user-configured notifyBelowPrice. Suppression is the watcher’s own 1% / 7-day rule, applied before dispatch.

data payload: type, watchId, price, currency, store.

SCHEDULED_BUY_DUE

Fired by ScheduledTransactionCheckerService.checkScheduledBuyNotificationsForUser (a second per-user branch alongside the existing auto-apply one) for due scheduled txs with applyAutomatically = false AND productId != null. The auto-apply branch keeps its silent-create behavior — SCHEDULED_BUY_DUE is specifically for the “buy decision” UX (the user has to walk into a store, so they get a nudge).

Dedup is enforced by the V41-added scheduled_buy_due_notifications table — composite PK on (user_id, scheduled_tx_id, occurrence_date). The scheduler does the insert first; a PK-collision short-circuits the dispatch. Re-runs in the same minute / day are harmless.

The notification body advertises the cheapest known store, computed at dispatch time from the latest observation per store (see Buy-decision support). data payload: type, scheduledTxId, productId, productName, cheapestStore (may be empty), cheapestUnitPrice (may be empty), currency (may be empty).

Tap routes to Screen.PlannedPurchases.route (where the BuyDecisionPanel inline-expands per row). Routing happens client-side in NotificationsScreen.NotificationRow — type-string check, not enum-based, since the client doesn’t share the server enum.

Client UI

ScreenFileWhat it shows
Product prices listshared/.../products/ui/screens/ProductPricesScreen.ktAdaptive layout (mobile: hero stats band, filter chips with counts, drop banner, product cards with sparklines; desktop: stat-card row + right rail of recent drops / coverage). Two chip rows — type (All / Ingredients / General) and category (All categories / DAIRY / …). Top-bar action: + to add a product. Stacked FABs (mobile): web search + manual log. Trend arrow + price-drop chip per card.
Product detail(same file)Hero with latest price + min/avg/max strip, segmented lookback control, MultiStorePriceSparkline (one colored line per store using storeColor()) with a FlowRow legend of per-store swatches, observation history with source badges. History adds a store-chip filter row: with “All” selected the list groups into per-store sections with colored headers and obs counts (deltas are computed within a store); picking a specific store flattens to that store’s chronology. Action row: Log purchase (primary), Log price, Schedule, ♡/♥ Wishlist, Compare stores, Watch, Refresh URL prices (when ≥1 obs has a URL), Similar on this site (same gate), Add to group / Detach from group (depending on parent state), Edit, Delete. Wraps via FlowRow on narrow widths.
Store comparison(same file)Per-store cards with min/avg/last numbers, BestTag on the cheapest, and a small inline PriceSparkline of that store’s own observations beneath the numbers when ≥ 2 points exist. The panel is fed detail.observations from the screen (so per-store sparkline needs no new API).
Add product dialog(same file)Four fields: Name, Brand (optional), Type segmented (Ingredient/General), Category dropdown filtered by type. Opens from the top-bar + or from the empty-state primary action. A “Track as a research group” switch sits between Name and Brand — flipping it on collapses the form to just the umbrella name (Brand / Type / Category are hidden because a parent has no meaningful single price, store, or category), changes the title to “Add research group”, and submits as productType = GENERAL. This is the discoverable path to create a parent umbrella; the prior route (create a standalone product then attach a candidate to it) still works but is no longer required.
Edit product dialog(same file)Pre-filled from ProductDetailResponse. Same shape as Add (Name + Brand + Type + Category dropdown filtered by type). On Save, ProductPricesViewModel.editProduct per-field diffs against the loaded detail and only fires PATCH calls for changed fields (name first, then brand, then type, then category, with an explicit clearCategory boolean so “None” is distinguishable from “no change”). Opens from the Edit button on the detail action row.
Delete confirmation(same file)Triggered by the Delete button on the detail action row. AlertDialog warns how many observations will be hidden and — if the product is a parent — how many candidates will be detached back to standalone. Confirm fires DELETE /products/{id} → on success the screen pops back to the list and the row is dropped from local state. Soft-delete only: observations stay in the DB so a future undo / restore flow can flip Products.status back.
Manual entryAddPriceObservationScreen.ktPicker is across the full catalog (no type filter; productType = null passed to /products/catalog), with category chips above it derived from the categories present in the user’s catalog (not all 22 enum values). Each picker row shows brand subtitle + a ProductTypeChip so INGREDIENT vs GENERAL stays readable when mixed. Form fields: product · store · unit price · quantity · currency · Notes (optional, multi-line, 3 lines max) · Product URL (optional, URI keyboard). Both new fields are nullable and trimmed-to-null on submit.
Log purchase / SchedulePurchaseDialogs.ktAdaptive: AlertDialog on desktop (≥ 900dp), bottom sheet (Dialog + bottom-anchored Surface) on mobile. Shared form body with hoisted state. Pre-filled from the product’s last observation + category.
Web searchWebProductSearchScreen.ktQuery field + result list (title, store, price, currency). Per-row inline product picker + Open / Import.
Watched productsWatchedProductsScreen.ktList of watches with Check now + delete. Add via modal with product picker + interval slider (6..168) + notify-below price.
Planned purchasesPlannedPurchasesScreen.ktHero band (Scheduled count / Wishlist count / Total) + two sections. Scheduled rows show cadence + amount + Finance category. Wishlist rows show added-date + last price + a trailing heart to remove. Tap any row → product detail.

All screens use the shared OterSnackbarHost + ErrorSnackbarEffect for transient errors. The web-search “Open” button uses LocalUriHandler.current.openUri(...) — URLs are validated server-side before they’re stored.

Sparkline implementation

Two related composables share the file shared/.../oter/products/ui/components/PriceSparkline.kt. Both use Compose Canvas + Path (no external charting dependency); both no-op when fewer than two distinct observations exist; both parse observation timestamps via DateUIUtils.toLocalDateTime() — do not trust lexicographic sort on "dd/MM/yyyy HH:mm" strings.

  • PriceSparkline — single-series chart. Used in two places: (a) the desktop product card (last 14 observations blended across stores, color-tinted by trend direction), and (b) the per-store cards in the Compare Stores panel (a tiny 40dp variant of the same component, filtered to one store’s observations).
  • MultiStorePriceSparkline — multi-line overlay used by the “Price over time” chart on the detail screen. Groups observations by storeName, draws one line per store using the existing storeColor() helper, on shared X (time) / Y (price) axes. Replaces the previous single-blended chart that visually merged all stores into one zig-zag. Followed by a FlowRow legend of per-store swatches. Time math uses a stable epoch-seconds approximation derived from LocalDateTime components (no Instant conversion — avoids kotlin.time vs kotlinx.datetime mismatch).

Code map

LayerLocation
MigrationsV11__receipts_and_product_prices.sql, V16__product_type_and_ingredient_link.sql, V19__product_price_observations_observed_at_index.sql, V31__products_feature_flag.sql, V33__product_price_watch.sql, V34__product_category.sql, V36__transaction_product_link.sql, V37__product_wishlist.sql, V39__product_brand.sql, V40__product_observation_notes.sql (adds nullable notes VARCHAR(1024) to product_price_observations), V41__scheduled_buy_due_notifications.sql (dedup table for buy-due pings; PascalCase-tolerant DO $$ guard à la V36 so it works on dev DBs with "ScheduledTransactions" quoted identifier), V48__product_parent.sql (adds nullable parent_product_id UUID self-reference + partial index for the research-umbrella feature; ON DELETE SET NULL).
Entitiesserver/.../database/entities/ReceiptEntities.kt (Products — gains product_category, wishlist_added_at, brand, parent_product_id; ProductPriceObservations — gains notes; WatchedProducts, WebProductSearchRuns, ObservationSource enum). Cross-feature: Transaction.kt / ScheduledTransactions.kt gain a nullable product FK. ScheduledBuyDueNotifications.kt — composite-PK dedup table for SCHEDULED_BUY_DUE.
Servicesserver/.../service/ProductPriceService.kt (price + catalog + category + wishlist; recordManualObservation accepts notes and productUrl; setParent / listChildren / loadChildrenCounts for research umbrellas; findObservation / listObservationsWithUrls for URL re-scrape lookups; updateProductName keeps normalized_key in sync on rename; deleteProduct flips status = INACTIVE and detaches any children pointing at this row), ProductPriceWatchService.kt, ProductPriceWatchRefreshScheduler.kt (24 h cadence + per-watch URL re-scrape pass), WebProductSearchService.kt (siteFilter param for site:<host> SerpApi scoping), ProductUrlScrapeService.kt (new — Jsoup-based JSON-LD / OG / microdata price extraction; defaultClient() uses HttpClient(CIO) + HttpTimeout + HttpRequestRetry). Finance bridge: TransactionService.createTransaction(..., productId?), ScheduledTransactionService.createScheduledTransaction(..., productId?). ScheduledTransactionCheckerService.kt gains a second per-user branch (checkScheduledBuyNotificationsForUser) that dispatches SCHEDULED_BUY_DUE for non-auto-apply product-linked rows; takes new deps ProductPriceService, NotificationDispatcher, TimerService (wire in KoinConfig.kt).
Reposerver/.../repository/ProductRepository.kt (passthroughs incl. setParent / listChildren / updateProductName / deleteProduct), TransactionRepository.kt, ScheduledTransactionRepository.kt
Routesserver/.../routing/ProductRouting.kt (PATCH /{id}/parent, GET /{id}/children, PATCH /{id}/name, DELETE /{id}), ProductSearchRouting.kt (POST /{id}/refresh-prices, POST /observations/{id}/refresh-url, POST /{id}/similar). FinanceRouting.kt forwards productId on the existing POST routes.
Notificationsserver/.../service/NotificationType.ktSCHEDULED_BUY_DUE enum value (throttleKey = null; dedup happens at dispatch).
Configserver/.../utils/Config.kt (SerpApiConfig)
Normalizersserver/.../utils/ProductNameNormalizer.kt, StoreNameNormalizer.kt
Shared modelsshared/.../oter/products/model/ProductModels.ktProductPriceSummary and ProductDetailResponse gain productCategory, wishlistAddedAt, brand, parentProductId, childrenCount; ProductObservationEntry gains notes, productUrl, and id (nullable for back-compat); CreateProductObservationRequest gains notes and productUrl; new RefreshProductPricesRequest, SetParentProductRequest, UpdateProductNameRequest. Also ProductCategory.kt, WebSearchModels.kt (adds SimilarSearchScope, SimilarSearchRequest, RefreshUrlPriceResponse), TrendDirection.kt, ProductType.kt. Finance: Transaction.kt and ScheduledTransaction.kt carry productId: String?.
Trend mathshared/.../oter/products/TrendCalculator.kt
Client APIshared/.../oter/products/services/ProductPricesApi.kt (refreshProductPrices(productId, storesFilter), refreshObservationUrl(observationId), searchSimilar(productId, scope, referenceUrl), setProductParent(productId, parentId?), listProductChildren(parentId), updateProductName(productId, canonicalName), deleteProduct(productId); listProductCatalog.productType parameter nullable with default INGREDIENT), ProductPricesService.kt. Finance scheduled: FinanceScheduledTransactionService.getProductScheduledTransactions(limit).
ViewModelsshared/.../oter/products/ui/viewmodels/ProductPricesViewModel.kt (existing refreshProductPrices, createProduct, updateProductCategory, toggleWishlist; new refreshObservationUrl, refreshAllUrlsForCurrentDetail, searchSimilar, setProductParent, editProduct (per-field PATCH diff), deleteProduct (soft); state gains childrenForCurrentDetail + isLoadingChildren; loadProductDetail auto-fetches children when detail.childrenCount > 0), PlannedPurchasesViewModel.kt (suspend hooks fetchProductDetail(productId) + refreshProductPrices(productId) for the inline panel), WebProductSearchViewModel.kt (searchSimilar(productId, scope) method that hits /products/{id}/similar and merges results into the regular results state).
Screensshared/.../oter/products/ui/screens/ProductPricesScreen.kt (list, detail, three CTAs + heart toggle + Add product dialog; multi-store chart + store-chip history filter / group-by-store + per-store sparkline on Compare panel; tap-to-open button on observation rows with productUrl; “Refresh URL prices” + “Similar on this site” actions on the detail FlowRow (visible only when ≥1 observation has a URL); “Add to group” / “Detach from group” actions for research umbrellas; Candidates strip with CandidateChildCards when childrenCount > 0; ”★ N candidates” badge on list rows that are parents; ParentPickerDialog; “Track as a research group” switch inside AddProductDialog that collapses the form to umbrella-only fields and changes the title; new EditProductDialog opened from the “Edit” action on the detail row (per-field PATCH diff against the loaded detail); Delete confirmation AlertDialog opened from “Delete” that warns about hidden observations + detached candidates and pops the screen on success), AddPriceObservationScreen.kt (full-catalog picker + category chips + brand subtitle + ProductTypeChip + Notes field + Product URL field), WebProductSearchScreen.kt (accepts a similarMode: SimilarSearchScope? arg — when non-null + prefillProductId set, calls vm.searchSimilar(...) instead of the regular searchWeb() on launch), WatchedProductsScreen.kt, PurchaseDialogs.kt (Log dialog now embeds BuyDecisionPanel read-only between Amount and Account), PlannedPurchasesScreen.kt (scheduled rows gain a chevron toggle + inline BuyDecisionPanel).
UI atomsshared/.../oter/products/ui/components/ProductAtoms.kt (ProductCategoryChip, ProductTypeChip, categoriesFor, categoryLabel, categoryGlyph, categoryAccent, defaultFinanceCategoryFor, inferCategoryFromName, PpHeroStat, HeroSep, BestTag, etc.)
Sparklineshared/.../oter/products/ui/components/PriceSparkline.kt — both PriceSparkline (single series) and the new MultiStorePriceSparkline (one line per store, shared axes).
Buy-decision panelshared/.../oter/products/ui/components/BuyDecisionPanel.kt — see Buy-decision support. Now also accepts an onRefreshObservationUrl: ((String) -> Unit)? callback and a refreshingObservationId: String? — when set, rows with both a URL and a known obs.id render a per-row button that fires the URL-refresh endpoint.
Navigation type-safe routeshared/.../oter/navigation/Routes.kt → WebProductSearchRoutegains optional similarMode: String? (carries SimilarSearchScope.name). AppNavHost decodes it back to the enum and forwards to WebProductSearchScreen.similarMode.
Notification routingshared/.../oter/ui/screens/NotificationsScreen.ktSCHEDULED_BUY_DUE row taps fire onOpenPlannedPurchases (wired at AppNavHost.kt to Screen.PlannedPurchases.route).
Nutrition integrationshared/.../oter/nutrition/ui/IngredientEditorRow.kt, shared/.../oter/products/RecipeCostEstimate.kt
Navigationshared/.../oter/navigation/Routes.kt (Screen.ProductPrices, Screen.WatchedProducts, Screen.PlannedPurchases, AddPriceObservationRoute, WebProductSearchRoute), AppNavHost.kt, AppMenuDefinition.kt (desktop sidebar entries), shared/.../oter/ui/screens/MoreScreens.kt (mobile More menu)
Desktop URI handlercomposeApp/src/desktopMain/.../utils/SafeDesktopUriHandler.kt — installed via CompositionLocalProvider(LocalUriHandler provides …) in Main.kt to survive Linux JVMs where java.awt.Desktop.browse(...) throws

Testing

TestPatternCases
TrendCalculatorTest (commonTest)kotlin.test, pureempty, only-recent, only-prior, ±5% boundary, single sample per window, multi-currency drop, observations beyond 30d ignored
ProductPricesViewModelTest (commonTest)runTest + hand-rolled FakeProductPricesApiload happy/error + clearError, submitObservation happy/error, splice into product list
WebProductSearchViewModelTest (commonTest)runTest + hand-rolled FakeWebApisearch happy/error, importResult, loadWatches, optimistic deleteWatch
WebProductSearchServiceTest (server)Ktor MockEngine (first such test in the repo)blank key → failure, canned JSON parsed dropping rows without extracted_price, 429 → failure, blank query short-circuits
ProductPriceWatchRefreshSchedulerTest (server)Real Postgres on 5431 + mockk for the search service / dispatcher / timer service≥1% drop inserts with WEB_SEARCH source, <1% delta skips insert + advances last_checked_at, notify_below_price fires PRICE_DROP, budget enforced, one watch failing doesn’t block others
ProductPriceServiceSearchTest (server)Real Postgres on 5431q filters canonical name, normalized key fallback, includes products without recent observations when q is set, excludes them otherwise
TransactionServiceProductLinkTest (server)Real Postgres on 5431Valid productId persists on insert + readable back; productId belonging to another user → IllegalArgumentException (route → 400)
ProductWishlistServiceTest (server)Real Postgres on 5431Toggle on stamps timestamp, toggle off clears it; listWishlist returns only flagged products newest-first; cross-user toggle returns null

The server tests require a Postgres test database on localhost:5431 (testdb / testuser / testpassword) — same convention as the rest of the server test suite.

Troubleshooting

/products/search returns 503

  • SERPAPI_KEY is not set on the server. Set it (or the SerpApiConfig.apiKey value in your env) and restart.

Scheduler isn’t refreshing watches

  • Check SERPAPI_KEY is configured — the scheduler short-circuits each tick when unconfigured (logs SerpApi not configured; skipping tick).
  • Check web_product_search_runs.ran_at >= now-24h count vs. SERPAPI_DAILY_BUDGET. If the budget is exhausted the tick logs daily budget exhausted and exits.
  • Verify the watch’s last_checked_at + interval_hours <= nowfindDueWatches filters by this in-memory.

Receipt-scanned price has the wrong source

  • Confirm the row’s receipt_line_item_id is non-null. recordObservationInTx writes source = RECEIPT explicitly; the V33 migration backfilled rows without a receipt link to MANUAL.

PRICE_DROP notification never arrives

  • Verify notify_below_price is set on the watch and that the latest observed unit price is <= notify_below_price.
  • Check the user has at least one device token registered (getDeviceTokensForUser).
  • The 1%-change rule may suppress the observation but the notification fires when the new price is below threshold regardless — confirm via web_product_search_runs.result_count > 0.

Hard-discount stores (D1, Ara, Justo & Bueno) never show up in web search

  • They do not advertise to Google Shopping. No aggregator covers them. Use manual entry for those.

Trend always shows FLAT

  • The product needs observations in both the recent (14d) and prior (16d) windows of the same currency. A single-source watch that only inserts when the price changes by ≥1% can produce sparse history — check ProductPriceObservations directly.

V36 fails locally with relation "scheduled_transactions" does not exist

  • The Exposed entities Transactions / ScheduledTransactions ship without an explicit table name; on fresh dev DBs they may have been auto-created as quoted PascalCase identifiers ("ScheduledTransactions") rather than snake_case. V36 was rewritten to use ALTER TABLE IF EXISTS + DO $$ guards that skip the missing table — re-run on next boot. The migration is a no-op on envs that don’t have the snake_case table; the FK lands when the schemas are aligned. Flyway also calls repair() first so failed entries get cleared.

Heart toggle on product detail does nothing

  • Verify the server is running a build newer than the V37 deploy. PATCH /products/{id}/wishlist requires the server-side route, which only exists after V37 + the matching server code. Curl PATCH /api/v1/products/{id}/wishlist with {"added": true} — should return the updated ProductPriceSummary with wishlistAddedAt set.

Planned purchases screen shows nothing under Scheduled even though I created scheduled transactions

  • The filter is ?productOnly=true — it returns only scheduled transactions with a non-null product_id. Scheduled transactions created before V36 won’t appear because they have no product_id. New schedules created via the Schedule button on a product detail include the productId; ones created directly in Finance don’t unless the Finance UI is updated to attach a product.

Categories I expect (e.g. acoustic guitar, gym membership) don’t fit

  • Acoustic instruments land in HOBBIES. Gym equipment lands in FITNESS. Gym membership is not a product — it’s a recurring service. Track it as a Finance ScheduledTransaction with Category.SUBSCRIPTION or ENTERTAINMENT, no product attached. Same for Spotify / Netflix / paid apps with monthly fees.

POST /products/observations/{id}/refresh-url returns 422 “No price found on page”

  • The page didn’t surface a price via JSON-LD, Open Graph, or microdata. Three things to verify in order: (1) the URL is publicly reachable without login (open it in a private window); (2) the price isn’t only rendered after client-side JS executes (view-source: the page and grep for the price string — if it’s not in raw HTML, ProductUrlScrapeService can’t see it); (3) the page actually has structured product data (look for <script type="application/ld+json"> blocks containing "@type": "Product"). If the site has no structured data, fall back to manual entry; a per-site adapter would be needed for first-class support.

POST /products/{id}/similar with SAME_SITE returns 400 “No reference URL available”

  • Either the request didn’t include referenceUrl and the product has no observation with a non-null productUrl. Add an observation with a URL first (manual entry or web-import), or pass referenceUrl explicitly in the body.

PATCH /products/{id}/parent returns 422

  • Hits the single-level hierarchy guard. Likely causes: the prospective parent is itself a child (Products.parent_product_id IS NOT NULL), or the product you’re trying to attach already has children pointing at it. The UI’s parent picker filters out ineligible candidates client-side; the 422 only fires when the request bypasses the UI’s filter (e.g., raw curl) or when state changed between picker render and submit. Detach the candidate’s grandchildren first, then re-attach.

Candidates strip on a parent’s detail page is empty after attaching the first candidate

  • The candidate list is loaded by ProductPricesViewModel.loadProductDetail only when the loaded detail.childrenCount > 0. Right after the first attach, the detail in state still has childrenCount = 0 from a prior fetch. The setProductParent method re-fetches the detail when the just-attached parent is the currently-open detail, but if you attached from a different screen, navigate to the parent’s detail (or pull-to-refresh) once. loadChildrenForDetail is private — there’s intentionally no manual reload button.

Watch scheduler didn’t fire for 24 hours after I created my first watch

  • The new loop cadence is 24 h (was 30 min before). The first tick fires on app boot, then once per day. For an immediate refresh use POST /products/watches/{id}/check-now (runs off-schedule, SerpApi pass only). Per-product URL re-scrapes only happen as part of the in-loop tick — there’s no on-demand “URL pass everything” endpoint.

web_product_search_runs.provider = URL_SCRAPE rows count against my SerpApi quota

  • They don’t. Only provider = SERPAPI rows are counted by callsInLast24h (the budget guard). URL_SCRAPE rows are audited for visibility but do not consume the cap. If your dashboard is summing both, scope it to provider = 'SERPAPI'.

I can’t find where to add a “research umbrella” product

  • Top-bar + (or the empty-state “Add product” button) → in the Add product dialog, flip the “Track as a research group” switch. The form collapses to just the umbrella name and the title changes to “Add research group”. Submitting creates the parent immediately. Then open another standalone product’s detail and use “Add to group” to attach a candidate.

I can’t find where to edit or delete a product

  • Open the product list (sidebar → Shopping → Product prices, or mobile More → Shopping → Product prices) → tap a product → scroll to the action row at the bottom of the detail page. Edit and Delete sit alongside Log purchase / Compare stores / Watch. There is no inline edit / delete on the list cards in v1 — always navigate via the detail page.

I deleted a product by mistake. Can I restore it?

  • Soft-delete only — the row is set to status = INACTIVE and observations stay ACTIVE. There is no client-side undo in v1; restore is a manual DB flip (UPDATE products SET status = 'ACTIVE' WHERE id = '<uuid>' AND user_id = <uid>). The price observations are preserved verbatim and will reappear on the next list / detail fetch. Children that were detached on delete stay detached — you’d need to re-attach them manually via “Add to group”.

The “Edit” save returns 404

  • The product was deleted (or hidden by status != ACTIVE) between the time you opened the dialog and submitted. The dialog has no real-time presence check. Close the dialog, hit refresh, and the row should be gone from the list.

The “Edit” rename succeeds but the product disappears from the list

  • The renamed row collided with another product’s normalized_key. We currently surface the server error verbatim on the screen-level snackbar but don’t refuse the rename at the dialog level. Soft-delete the duplicate first, or rename to something distinct.
  • Bills reader (receipt scan) — receipt-driven ingestion path
  • Nutrition — recipe ingredient cost estimation via RecipeCostEstimate.estimateRecipeIngredientCost
  • NotificationsPRICE_DROP joins the existing dispatcher / inbox / FCM pipeline
  • NavigationScreen.ProductPrices, Screen.WatchedProducts, Screen.PlannedPurchases, WebProductSearchRoute, AddPriceObservationRoute
  • Web search provider analysis — cost / swap target if we ever move off SerpApi