diff --git a/.env.example b/.env.example
index f73d292..0ab8727 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,5 @@ POSTGRES_PASSWORD=postgres
NEMOTRON_API_KEY=
VISION_API_KEY=
BLS_API_KEY=
+EBAY_CLIENT_ID=
+EBAY_CLIENT_SECRET=
diff --git a/Features/PhysicalAssets/InventoryList.razor b/Features/PhysicalAssets/InventoryList.razor
index 9c821d0..67c535e 100644
--- a/Features/PhysicalAssets/InventoryList.razor
+++ b/Features/PhysicalAssets/InventoryList.razor
@@ -370,8 +370,8 @@ else
}
}
- // PA10.1/PA10.2: re-runs the (currently AI-estimated, see #138) valuation
- // pipeline for this item's existing identification and appends the
+ // PA10.1/PA10.2: re-runs the market valuation pipeline for this item's
+ // existing identification and appends the
// result as a new history entry - it never overwrites prior valuations.
private async Task RevalueAsync()
{
diff --git a/Features/PhysicalAssets/PhysicalAssets.razor b/Features/PhysicalAssets/PhysicalAssets.razor
index c6c4cfa..3657144 100644
--- a/Features/PhysicalAssets/PhysicalAssets.razor
+++ b/Features/PhysicalAssets/PhysicalAssets.razor
@@ -575,8 +575,8 @@
///
/// In-memory review state for one DetectedAsset: whether the user is
- /// keeping it, any brand/model correction, an optional AI-estimated
- /// valuation (#138), and (#72-74) whether/where it's been saved to the
+ /// keeping it, any brand/model correction, an optional market valuation,
+ /// and (#72-74) whether/where it's been saved to the
/// inventory. This is the seam between PA3's detection output and
/// PA7's save step.
///
diff --git a/HumanCapital/IMarketPotentialPipeline.cs b/HumanCapital/IMarketPotentialPipeline.cs
index 16d9809..8ba5f99 100644
--- a/HumanCapital/IMarketPotentialPipeline.cs
+++ b/HumanCapital/IMarketPotentialPipeline.cs
@@ -14,9 +14,9 @@ namespace MoneyMirror.HumanCapital;
/// (#148), whose UI explicitly labels its output "AI estimate - not based
/// on live wage data". This isn't an oversight: this method needs a BLS
/// series ID per matched occupation, which was meant to come from O*NET
-/// occupation data (#93/#94, HC4) - but eBay and O*NET were both dropped
-/// from project scope (see #177) before that occupation-to-series mapping
-/// was built. Wiring this in requires deciding how to get that mapping
+/// occupation data (#93/#94, HC4) - but O*NET was dropped from project scope
+/// (see #177) before that occupation-to-series mapping was built. Wiring this
+/// in requires deciding how to get that mapping
/// without O*NET (a fixed lookup table for common titles? a second LLM
/// call to guess a series ID, no longer "no AI in choosing the number"?)
/// before this pipeline can replace the AI-guess path in the UI.
diff --git a/PhysicalAssets/AssetValuation.cs b/PhysicalAssets/AssetValuation.cs
index b8cf731..94a0f9d 100644
--- a/PhysicalAssets/AssetValuation.cs
+++ b/PhysicalAssets/AssetValuation.cs
@@ -4,11 +4,7 @@ namespace MoneyMirror.PhysicalAssets;
/// An estimated resale value for a physical asset.
///
///
-/// True when the value came directly from the LLM's
-/// judgment, not from real market comps. Never present this to the user
-/// (or persist it) as if it were evidence-based - #138 is a deliberate,
-/// temporary stand-in for the real PA5 (#14) -> PA6 (#68-71) pipeline,
-/// which aggregates real comps deterministically and sets this false.
+/// True when the value came from an LLM estimate rather than market listings.
///
public record AssetValuation(
decimal? EstimatedValueUsd,
diff --git a/PhysicalAssets/Configuration/EbayOptions.cs b/PhysicalAssets/Configuration/EbayOptions.cs
new file mode 100644
index 0000000..563d316
--- /dev/null
+++ b/PhysicalAssets/Configuration/EbayOptions.cs
@@ -0,0 +1,19 @@
+namespace MoneyMirror.PhysicalAssets.Configuration;
+
+/// Client-credentials app keys and Browse API endpoint configuration for eBay.
+/// Defaults point at eBay's sandbox environment; swap both URLs to the production hosts
+/// once the app has production-approved keys (sandbox listings are seeded test data, not
+/// a real catalog).
+public sealed class EbayOptions
+{
+ public const string SectionName = "Ebay";
+
+ public string ClientId { get; set; } = string.Empty;
+ public string ClientSecret { get; set; } = string.Empty;
+ public string AuthUrl { get; set; } = "https://api.sandbox.ebay.com/identity/v1/oauth2/token";
+ public string SearchUrl { get; set; } =
+ "https://api.sandbox.ebay.com/buy/browse/v1/item_summary/search";
+ public string MarketplaceId { get; set; } = "EBAY_US";
+ public int CacheDurationHours { get; set; } = 720;
+ public string CacheDirectory { get; set; } = "App_Data/ebay-search-cache";
+}
diff --git a/PhysicalAssets/EbayMarketDataException.cs b/PhysicalAssets/EbayMarketDataException.cs
new file mode 100644
index 0000000..a1bd216
--- /dev/null
+++ b/PhysicalAssets/EbayMarketDataException.cs
@@ -0,0 +1,8 @@
+namespace MoneyMirror.PhysicalAssets;
+
+/// Thrown when eBay's Browse API cannot return comparable listings.
+public sealed class EbayMarketDataException : Exception
+{
+ public EbayMarketDataException(string message, Exception? innerException = null)
+ : base(message, innerException) { }
+}
diff --git a/PhysicalAssets/EbayMarketDataService.cs b/PhysicalAssets/EbayMarketDataService.cs
new file mode 100644
index 0000000..ceedacd
--- /dev/null
+++ b/PhysicalAssets/EbayMarketDataService.cs
@@ -0,0 +1,298 @@
+using System.Globalization;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Options;
+using MoneyMirror.PhysicalAssets.Configuration;
+
+namespace MoneyMirror.PhysicalAssets;
+
+/// Searches eBay's Browse API for priced used/refurbished listings via a
+/// client-credentials app token. Defaults to eBay's sandbox, whose inventory is seeded
+/// test data - it answers real requests but rarely has comps for a real product name, and
+/// its own condition filter does not reliably exclude new items, so results are always
+/// re-checked against 's USD/condition requirements
+/// here rather than trusted as returned.
+public sealed class EbayMarketDataService : IMarketDataService
+{
+ private const int ResultLimit = 50;
+ private const string ConditionFilter = "conditions:{USED|CERTIFIED_REFURBISHED|SELLER_REFURBISHED}";
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly HttpClient _httpClient;
+ private readonly EbayOptions _options;
+ private readonly MarketDataSearchCache _searchCache;
+ private readonly EbayTokenCache _tokenCache;
+
+ public EbayMarketDataService(
+ HttpClient httpClient,
+ IOptions options,
+ MarketDataSearchCache searchCache,
+ EbayTokenCache tokenCache
+ )
+ {
+ _httpClient = httpClient;
+ _options = options.Value;
+ _searchCache = searchCache;
+ _tokenCache = tokenCache;
+ }
+
+ public async Task> SearchUsedListingsAsync(
+ string label,
+ string? brand,
+ string? model,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+
+ var query = string.Join(
+ " ",
+ new[] { label, brand, model }
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Select(value => value!.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ );
+
+ var key = BuildCacheKey(query);
+ var cacheLifetime = TimeSpan.FromHours(Math.Clamp(_options.CacheDurationHours, 1, 24 * 365));
+ return await _searchCache.GetOrCreateAsync(
+ key,
+ cacheLifetime,
+ () => SearchProviderAsync(query, cancellationToken),
+ cancellationToken
+ );
+ }
+
+ private async Task> SearchProviderAsync(
+ string query,
+ CancellationToken cancellationToken
+ )
+ {
+ EnsureCredentials();
+ var token = await _tokenCache.GetOrCreateAsync(ct => FetchTokenAsync(ct), cancellationToken);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, BuildSearchUri(query));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", _options.MarketplaceId);
+
+ using var response = await SendAsync(request, cancellationToken);
+ var result = await ReadResponseAsync(
+ response,
+ "eBay Browse API search",
+ cancellationToken
+ );
+
+ return MapEvidence(result);
+ }
+
+ private async Task<(string Token, TimeSpan ExpiresIn)> FetchTokenAsync(
+ CancellationToken cancellationToken
+ )
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, _options.AuthUrl)
+ {
+ Content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["scope"] = "https://api.ebay.com/oauth/api_scope",
+ }
+ ),
+ };
+ var credentials = Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{_options.ClientId}:{_options.ClientSecret}")
+ );
+ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
+
+ using var response = await SendAsync(request, cancellationToken);
+ var result = await ReadResponseAsync(
+ response,
+ "eBay OAuth token request",
+ cancellationToken
+ );
+
+ if (string.IsNullOrWhiteSpace(result.AccessToken))
+ {
+ throw new EbayMarketDataException("eBay OAuth token response did not include a token.");
+ }
+
+ return (result.AccessToken, TimeSpan.FromSeconds(Math.Max(result.ExpiresIn, 60)));
+ }
+
+ private string BuildCacheKey(string query)
+ {
+ var normalizedQuery = System.Text.RegularExpressions.Regex
+ .Replace(query.Normalize(NormalizationForm.FormKC), @"\s+", " ")
+ .Trim()
+ .ToLowerInvariant();
+ var keyMaterial = string.Join(
+ "\n",
+ _options.SearchUrl.Trim(),
+ _options.MarketplaceId.Trim().ToUpperInvariant(),
+ _options.CacheDurationHours.ToString(CultureInfo.InvariantCulture),
+ normalizedQuery
+ );
+ return Convert
+ .ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyMaterial)))
+ .ToLowerInvariant();
+ }
+
+ private void EnsureCredentials()
+ {
+ if (string.IsNullOrWhiteSpace(_options.ClientId) || string.IsNullOrWhiteSpace(_options.ClientSecret))
+ {
+ throw new EbayMarketDataException(
+ "eBay credentials are not configured. Set Ebay:ClientId and Ebay:ClientSecret."
+ );
+ }
+ }
+
+ private Uri BuildSearchUri(string query)
+ {
+ var parameters = new Dictionary
+ {
+ ["q"] = query,
+ ["filter"] = ConditionFilter,
+ ["limit"] = ResultLimit.ToString(CultureInfo.InvariantCulture),
+ };
+ var queryString = string.Join(
+ "&",
+ parameters.Select(pair =>
+ $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"
+ )
+ );
+
+ return new Uri($"{_options.SearchUrl.TrimEnd('?', '&')}?{queryString}", UriKind.Absolute);
+ }
+
+ private static IReadOnlyList MapEvidence(SearchResponse result)
+ {
+ var evidence = new List();
+
+ foreach (var item in result.ItemSummaries ?? [])
+ {
+ var condition = NormalizeCondition(item.ConditionId);
+ if (
+ condition is null
+ || string.IsNullOrWhiteSpace(item.Title)
+ || item.Price is not { Currency: "USD" } price
+ || !decimal.TryParse(
+ price.Value,
+ NumberStyles.Number,
+ CultureInfo.InvariantCulture,
+ out var priceUsd
+ )
+ || priceUsd <= 0
+ )
+ {
+ continue;
+ }
+
+ evidence.Add(new AssetValuationEvidence(priceUsd, "eBay", item.Title.Trim(), condition));
+ }
+
+ return evidence.Distinct().Take(ResultLimit).ToArray();
+ }
+
+ // eBay's documented conditionId taxonomy: 1000-1999 covers New/New-with-defects, 2000-2999
+ // is the Refurbished band (certified/seller/excellent/very-good/good), 3000-6999 is Used at
+ // every grade (Used, Very Good, Good, Acceptable), and 7000 is "for parts or not working" -
+ // not representative of a working item's resale value, so it is excluded like New.
+ private static string? NormalizeCondition(string? conditionId)
+ {
+ if (!int.TryParse(conditionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
+ {
+ return null;
+ }
+
+ return id switch
+ {
+ >= 2000 and < 3000 => "Refurbished",
+ >= 3000 and < 7000 => "Used",
+ _ => null,
+ };
+ }
+
+ private async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ )
+ {
+ try
+ {
+ return await _httpClient.SendAsync(request, cancellationToken);
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new EbayMarketDataException("Failed to reach eBay.", ex);
+ }
+ catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new EbayMarketDataException("eBay request timed out.", ex);
+ }
+ }
+
+ private static async Task ReadResponseAsync(
+ HttpResponseMessage response,
+ string what,
+ CancellationToken cancellationToken
+ )
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new EbayMarketDataException(
+ $"{what} returned {(int)response.StatusCode} {response.StatusCode}."
+ );
+ }
+
+ try
+ {
+ var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ return result ?? throw new EbayMarketDataException($"{what} returned an empty response.");
+ }
+ catch (JsonException ex)
+ {
+ throw new EbayMarketDataException($"Failed to parse the {what} response.", ex);
+ }
+ }
+
+ private sealed class TokenResponse
+ {
+ [JsonPropertyName("access_token")]
+ public string? AccessToken { get; init; }
+
+ [JsonPropertyName("expires_in")]
+ public int ExpiresIn { get; init; }
+ }
+
+ private sealed class SearchResponse
+ {
+ [JsonPropertyName("itemSummaries")]
+ public IReadOnlyList? ItemSummaries { get; init; }
+ }
+
+ private sealed class ItemSummary
+ {
+ [JsonPropertyName("title")]
+ public string? Title { get; init; }
+
+ [JsonPropertyName("price")]
+ public Money? Price { get; init; }
+
+ [JsonPropertyName("conditionId")]
+ public string? ConditionId { get; init; }
+ }
+
+ private sealed class Money
+ {
+ [JsonPropertyName("value")]
+ public string? Value { get; init; }
+
+ [JsonPropertyName("currency")]
+ public string? Currency { get; init; }
+ }
+}
diff --git a/PhysicalAssets/EbayTokenCache.cs b/PhysicalAssets/EbayTokenCache.cs
new file mode 100644
index 0000000..2791c6c
--- /dev/null
+++ b/PhysicalAssets/EbayTokenCache.cs
@@ -0,0 +1,45 @@
+namespace MoneyMirror.PhysicalAssets;
+
+/// Caches eBay's client-credentials OAuth token in memory so every search does not
+/// spend a round trip re-authenticating. Registered as a singleton:
+/// is a typed HttpClient, which is transient, so a per-instance cache would never be reused.
+public sealed class EbayTokenCache(TimeProvider? timeProvider = null)
+{
+ // A token is refreshed a minute before it actually expires, so a request in flight
+ // never races an expiring token.
+ private static readonly TimeSpan ExpiryMargin = TimeSpan.FromMinutes(1);
+
+ private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private string? _token;
+ private DateTimeOffset _expiresAtUtc = DateTimeOffset.MinValue;
+
+ public async Task GetOrCreateAsync(
+ Func> factory,
+ CancellationToken cancellationToken
+ )
+ {
+ if (_token is { } cached && _timeProvider.GetUtcNow() < _expiresAtUtc)
+ {
+ return cached;
+ }
+
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ if (_token is { } stillCached && _timeProvider.GetUtcNow() < _expiresAtUtc)
+ {
+ return stillCached;
+ }
+
+ var (token, expiresIn) = await factory(cancellationToken);
+ _token = token;
+ _expiresAtUtc = _timeProvider.GetUtcNow() + expiresIn - ExpiryMargin;
+ return token;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+}
diff --git a/PhysicalAssets/EvidenceBasedAssetValuationService.cs b/PhysicalAssets/EvidenceBasedAssetValuationService.cs
new file mode 100644
index 0000000..7938aed
--- /dev/null
+++ b/PhysicalAssets/EvidenceBasedAssetValuationService.cs
@@ -0,0 +1,46 @@
+namespace MoneyMirror.PhysicalAssets;
+
+/// Builds an asset valuation from structured market listings, without an LLM.
+/// Falls back to 's price guess when the market-data
+/// provider itself fails (credentials, network, rate limit) - not when it succeeds with zero
+/// usable listings, which is already a valid, explicit "no market value" result.
+public sealed class EvidenceBasedAssetValuationService : IAssetValuationService
+{
+ private readonly IMarketDataService _marketDataService;
+ private readonly AiEstimatedValuationService _fallback;
+ private readonly TimeProvider _timeProvider;
+
+ public EvidenceBasedAssetValuationService(
+ IMarketDataService marketDataService,
+ AiEstimatedValuationService fallback,
+ TimeProvider? timeProvider = null
+ )
+ {
+ _marketDataService = marketDataService;
+ _fallback = fallback;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public async Task EstimateAsync(
+ string label,
+ string? brand,
+ string? model,
+ CancellationToken cancellationToken = default
+ )
+ {
+ try
+ {
+ var evidence = await _marketDataService.SearchUsedListingsAsync(
+ label,
+ brand,
+ model,
+ cancellationToken
+ );
+ return MarketValuationCalculator.Calculate(evidence, _timeProvider.GetUtcNow());
+ }
+ catch (EbayMarketDataException)
+ {
+ return await _fallback.EstimateAsync(label, brand, model, cancellationToken);
+ }
+ }
+}
diff --git a/PhysicalAssets/IAssetValuationService.cs b/PhysicalAssets/IAssetValuationService.cs
index e6df0fc..039ea5b 100644
--- a/PhysicalAssets/IAssetValuationService.cs
+++ b/PhysicalAssets/IAssetValuationService.cs
@@ -1,19 +1,14 @@
namespace MoneyMirror.PhysicalAssets;
///
-/// Estimates a physical asset's resale value. The current implementation
-/// (#138) is an explicit MVP placeholder that asks the LLM to guess a
-/// plausible value - it is not grounded in real market comps. It exists so
-/// the app has an end-to-end demo path before #14 (PA5, real market
-/// evidence) and #68-71 (PA6, deterministic aggregation of that evidence)
-/// are built; see #138 for the swap-out plan.
+/// Estimates a physical asset's resale value from comparable-market evidence.
///
public interface IAssetValuationService
{
/// A valuation whose EstimatedValueUsd is null when no market value is available.
/// Callers must preserve that distinction instead of treating it as zero.
///
- /// The LLM call failed, or its response couldn't be parsed.
+ /// The market-data provider failed or its response could not be parsed.
///
Task EstimateAsync(
string label,
diff --git a/PhysicalAssets/IMarketDataService.cs b/PhysicalAssets/IMarketDataService.cs
new file mode 100644
index 0000000..232b7a9
--- /dev/null
+++ b/PhysicalAssets/IMarketDataService.cs
@@ -0,0 +1,13 @@
+namespace MoneyMirror.PhysicalAssets;
+
+/// Searches an external market-data provider for priced used-item comparables.
+public interface IMarketDataService
+{
+ /// Returns priced used/refurbished listings matching an item's label, brand and model.
+ Task> SearchUsedListingsAsync(
+ string label,
+ string? brand,
+ string? model,
+ CancellationToken cancellationToken = default
+ );
+}
diff --git a/PhysicalAssets/MarketDataSearchCache.cs b/PhysicalAssets/MarketDataSearchCache.cs
new file mode 100644
index 0000000..bd35d29
--- /dev/null
+++ b/PhysicalAssets/MarketDataSearchCache.cs
@@ -0,0 +1,155 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+
+namespace MoneyMirror.PhysicalAssets;
+
+/// Caches successful market-data provider searches on disk so app restarts do not
+/// spend the same search again, and coalesces concurrent identical in-flight searches.
+public sealed class MarketDataSearchCache(string cacheDirectory)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private readonly ConcurrentDictionary<
+ string,
+ Lazy>>
+ > _inFlight = new();
+
+ public async Task> GetOrCreateAsync(
+ string key,
+ TimeSpan lifetime,
+ Func>> factory,
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var pending = _inFlight.GetOrAdd(
+ key,
+ _ => new Lazy>>(
+ () => LoadOrCreateAsync(key, lifetime, factory),
+ LazyThreadSafetyMode.ExecutionAndPublication
+ )
+ );
+ var task = pending.Value;
+ _ = task.ContinueWith(
+ _ =>
+ (
+ (ICollection<
+ KeyValuePair>>>
+ >)
+ _inFlight
+ ).Remove(
+ new KeyValuePair>>>(
+ key,
+ pending
+ )
+ ),
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default
+ );
+
+ return await task.WaitAsync(cancellationToken);
+ }
+
+ private async Task> LoadOrCreateAsync(
+ string key,
+ TimeSpan lifetime,
+ Func>> factory
+ )
+ {
+ try
+ {
+ Directory.CreateDirectory(cacheDirectory);
+ }
+ catch (IOException)
+ {
+ return await factory();
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return await factory();
+ }
+
+ var cachePath = Path.Combine(cacheDirectory, $"{key}.json");
+ var cached = await ReadAsync(cachePath);
+ if (
+ cached is { Evidence: not null, ExpiresAtUtc: var expiresAt }
+ && expiresAt > DateTimeOffset.UtcNow
+ )
+ {
+ return cached.Evidence;
+ }
+
+ var evidence = await factory();
+ await WriteAsync(cachePath, new CacheEntry(DateTimeOffset.UtcNow.Add(lifetime), evidence));
+ return evidence;
+ }
+
+ private static async Task ReadAsync(string path)
+ {
+ try
+ {
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ await using var stream = File.OpenRead(path);
+ return await JsonSerializer.DeserializeAsync(stream, JsonOptions);
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static async Task WriteAsync(string path, CacheEntry entry)
+ {
+ var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp";
+ try
+ {
+ await using (var stream = File.Create(temporaryPath))
+ {
+ await JsonSerializer.SerializeAsync(stream, entry, JsonOptions);
+ }
+
+ File.Move(temporaryPath, path, overwrite: true);
+ }
+ catch (IOException)
+ {
+ // The cache is an optimization; a storage hiccup must not fail a valuation.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // The cache is an optimization; a storage hiccup must not fail a valuation.
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(temporaryPath);
+ }
+ catch (IOException)
+ {
+ // Ignore temporary-file cleanup failures.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignore temporary-file cleanup failures.
+ }
+ }
+ }
+
+ private sealed record CacheEntry(
+ DateTimeOffset ExpiresAtUtc,
+ IReadOnlyList Evidence
+ );
+}
diff --git a/Program.cs b/Program.cs
index bc72c5e..17a6527 100644
--- a/Program.cs
+++ b/Program.cs
@@ -8,6 +8,7 @@
using MoneyMirror.HumanCapital;
using MoneyMirror.HumanCapital.Configuration;
using MoneyMirror.PhysicalAssets;
+using MoneyMirror.PhysicalAssets.Configuration;
// Containers start with no LANG/LC_ALL, so .NET falls back to the invariant culture
// and renders currency as "ยค" instead of "$". Pin the formatting culture so money
@@ -29,6 +30,9 @@
builder.Configuration.GetSection(NemotronOptions.SectionName)
);
builder.Services.Configure(builder.Configuration.GetSection(BlsOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(EbayOptions.SectionName)
+);
builder.Services.Configure(
builder.Configuration.GetSection(VisionModelOptions.SectionName)
);
@@ -41,6 +45,15 @@
builder.Services.AddTransient();
builder.Services.AddMemoryCache();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp =>
+{
+ var options = sp.GetRequiredService>().Value;
+ var cacheDirectory = Path.IsPathRooted(options.CacheDirectory)
+ ? options.CacheDirectory
+ : Path.Combine(builder.Environment.ContentRootPath, options.CacheDirectory);
+ return new MarketDataSearchCache(cacheDirectory);
+});
// Both AI clients share NVIDIA's endpoint, which sheds load with a 503 when its
// workers are saturated - see TransientFaultRetryHandler. HttpClient.Timeout
@@ -65,6 +78,13 @@
builder.Services.AddHttpClient(client =>
client.Timeout = TimeSpan.FromSeconds(15)
);
+builder.Services.AddHttpClient(client =>
+ client.Timeout = TimeSpan.FromSeconds(15)
+);
+
+// eBay's OAuth token is a Bearer header, not a query-string secret, but keep routine
+// HTTP logs at Warning so a future log-level bump cannot echo it either.
+builder.Logging.AddFilter("System.Net.Http.HttpClient.IMarketDataService", LogLevel.Warning);
builder.Services.AddScoped<
IMarketPotentialExplanationService,
NemotronMarketPotentialExplanationService
@@ -83,7 +103,11 @@
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
-builder.Services.AddScoped();
+// Registered concretely (not just via IAssetValuationService) so
+// EvidenceBasedAssetValuationService can take it as its fallback without a
+// circular resolution through the interface both of them implement.
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/appsettings.json b/appsettings.json
index d8ca2ae..2868530 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -24,5 +24,14 @@
"Bls": {
"ApiKey": "",
"BaseUrl": "https://api.bls.gov/publicAPI/v2/"
+ },
+ "Ebay": {
+ "ClientId": "",
+ "ClientSecret": "",
+ "AuthUrl": "https://api.sandbox.ebay.com/identity/v1/oauth2/token",
+ "SearchUrl": "https://api.sandbox.ebay.com/buy/browse/v1/item_summary/search",
+ "MarketplaceId": "EBAY_US",
+ "CacheDurationHours": 720,
+ "CacheDirectory": "App_Data/ebay-search-cache"
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 17172a2..2cf4da3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,6 +16,8 @@ services:
Ai__Nemotron__ApiKey: ${NEMOTRON_API_KEY:-}
Ai__VisionModel__ApiKey: ${VISION_API_KEY:-}
Bls__ApiKey: ${BLS_API_KEY:-}
+ Ebay__ClientId: ${EBAY_CLIENT_ID:-}
+ Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-}
ports:
- "${APP_PORT:-8000}:8080"
volumes:
diff --git a/docs/development.md b/docs/development.md
index c666f4d..2333c63 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -6,20 +6,44 @@ monolith, one database, one implicit user โ no auth or multi-tenancy.
## Configuration
-AI and BLS settings live under `Ai:Nemotron`, `Ai:VisionModel`, and `Bls`
-(`BaseUrl`, `Model` / `ApiKey` as applicable). `appsettings.json` ships empty
-`ApiKey` placeholders โ never commit real keys. Set them locally:
+AI, BLS, and eBay Browse API settings live under `Ai:Nemotron`,
+`Ai:VisionModel`, `Bls`, and `Ebay` (`ClientId`, `ClientSecret`, `AuthUrl`,
+`SearchUrl`, `MarketplaceId`, and `CacheDurationHours`). `appsettings.json`
+ships empty secret placeholders โ never commit real keys. Set them locally:
```sh
dotnet user-secrets set "Ai:Nemotron:ApiKey" ""
dotnet user-secrets set "Ai:VisionModel:ApiKey" ""
dotnet user-secrets set "Bls:ApiKey" ""
+dotnet user-secrets set "Ebay:ClientId" ""
+dotnet user-secrets set "Ebay:ClientSecret" ""
```
-Or use env vars: `Ai__Nemotron__ApiKey`, `Ai__VisionModel__ApiKey`, `Bls__ApiKey`.
-
-A free BLS v2 key from [data.bls.gov/registrationEngine](https://data.bls.gov/registrationEngine/)
-raises rate limits; the app works without one at the unregistered limit.
+Or use env vars: `Ai__Nemotron__ApiKey`, `Ai__VisionModel__ApiKey`,
+`Bls__ApiKey`, `Ebay__ClientId`, `Ebay__ClientSecret`.
+
+Physical asset valuations authenticate to eBay's Browse API with an
+OAuth2 client-credentials app token (cached in memory for its ~2-hour
+lifetime), then search for up to 50 listings filtered to used/refurbished
+condition and calculate a median from USD-priced results. eBay's own
+condition filter does not reliably exclude new items, so results are
+re-checked client-side against each listing's `conditionId`. No usable
+listings produce a null value and an explicit low-confidence explanation.
+If eBay itself fails - missing credentials, network error, rate limit -
+the valuation falls back to an LLM price guess (`AiEstimatedValuationService`)
+rather than failing the request outright. The valuation returns each
+comparable's price, source, title, and condition in `AssetValuation.Evidence`;
+that evidence is persisted and displayed alongside each valuation in
+inventory history. Successful results, including empty results, are cached
+for 30 days in `App_Data/ebay-search-cache`, which is on the persistent
+Docker app-data volume. Repeated or concurrent searches for the same item
+reuse that result.
+
+The default `AuthUrl`/`SearchUrl` point at eBay's **sandbox** environment,
+whose inventory is seeded test data - it answers real requests but rarely
+has comps for a real product name. Swap both URLs to the production hosts
+(`api.ebay.com` instead of `api.sandbox.ebay.com`) once the app has
+production-approved keys from the [eBay Developer Program](https://developer.ebay.com/).
## Run with Docker
@@ -37,6 +61,8 @@ on Postgres volumes and host-local `dotnet run`: [backend/postgresql_setup.md](.
| `NEMOTRON_API_KEY` | `Ai:Nemotron:ApiKey` |
| `VISION_API_KEY` | `Ai:VisionModel:ApiKey` |
| `BLS_API_KEY` | `Bls:ApiKey` |
+| `EBAY_CLIENT_ID` | `Ebay:ClientId` |
+| `EBAY_CLIENT_SECRET` | `Ebay:ClientSecret` |
`docker compose down` stops the stack; add `-v` to wipe DB and image volumes.
diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs
new file mode 100644
index 0000000..0e8f604
--- /dev/null
+++ b/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs
@@ -0,0 +1,353 @@
+using System.Net;
+using System.Text;
+using Microsoft.Extensions.Options;
+using MoneyMirror.PhysicalAssets;
+using MoneyMirror.PhysicalAssets.Configuration;
+
+namespace MoneyMirror.Tests.PhysicalAssets;
+
+public class EbayMarketDataServiceTests
+{
+ private const string TokenResponse =
+ """{"access_token":"test-access-token","expires_in":7200,"token_type":"Application Access Token"}""";
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_AuthenticatesThenSearchesAndMapsOnlyUsedOrRefurbishedUsdResults()
+ {
+ using var cache = new TemporarySearchCache();
+ RequestSnapshot? tokenRequest = null;
+ RequestSnapshot? searchRequest = null;
+ var handler = new CallbackHandler(async (request, cancellationToken) =>
+ {
+ if (request.RequestUri!.AbsolutePath.Contains("oauth2/token"))
+ {
+ tokenRequest = await RequestSnapshot.CaptureAsync(request, cancellationToken);
+ return JsonResponse(TokenResponse);
+ }
+
+ searchRequest = await RequestSnapshot.CaptureAsync(request, cancellationToken);
+ return JsonResponse(
+ """
+ {"itemSummaries":[
+ {"title":"Used Fender CD-60S Acoustic Guitar","price":{"value":"130.00","currency":"USD"},"conditionId":"3000"},
+ {"title":"New Fender CD-60S Acoustic Guitar","price":{"value":"300.00","currency":"USD"},"conditionId":"1000"},
+ {"title":"Certified Refurbished Fender CD-60S","price":{"value":"145.00","currency":"USD"},"conditionId":"2010"},
+ {"title":"For parts Fender CD-60S","price":{"value":"40.00","currency":"USD"},"conditionId":"7000"},
+ {"title":"Used guitar, foreign listing","price":{"value":"110.00","currency":"GBP"},"conditionId":"3000"}
+ ]}
+ """
+ );
+ });
+ var service = CreateService(handler, cache.Create(), new EbayTokenCache());
+
+ var listings = await service.SearchUsedListingsAsync("acoustic guitar", "Fender", "CD-60S");
+
+ Assert.Equal(2, listings.Count);
+ Assert.Contains(
+ listings,
+ item => item.PriceUsd == 130m && item.Source == "eBay" && item.Condition == "Used"
+ );
+ Assert.Contains(listings, item => item.PriceUsd == 145m && item.Condition == "Refurbished");
+ Assert.All(listings, item => Assert.NotNull(item.ListingTitle));
+
+ Assert.NotNull(tokenRequest);
+ Assert.Equal(HttpMethod.Post, tokenRequest.Method);
+ Assert.StartsWith("Basic ", tokenRequest.Authorization);
+ Assert.Contains("grant_type=client_credentials", tokenRequest.Body);
+
+ Assert.NotNull(searchRequest);
+ Assert.Equal(HttpMethod.Get, searchRequest.Method);
+ Assert.Equal("Bearer test-access-token", searchRequest.Authorization);
+ Assert.Equal("EBAY_US", searchRequest.MarketplaceId);
+ Assert.Contains("q=acoustic%20guitar%20Fender%20CD-60S", searchRequest.Uri.Query);
+ Assert.Contains(
+ "filter=conditions%3A%7BUSED%7CCERTIFIED_REFURBISHED%7CSELLER_REFURBISHED%7D",
+ searchRequest.Uri.Query
+ );
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_ReusesTheCachedTokenAcrossSearches()
+ {
+ using var cache = new TemporarySearchCache();
+ var tokenRequests = 0;
+ var handler = new CallbackHandler((request, _) =>
+ {
+ if (request.RequestUri!.AbsolutePath.Contains("oauth2/token"))
+ {
+ tokenRequests++;
+ return Task.FromResult(JsonResponse(TokenResponse));
+ }
+
+ return Task.FromResult(JsonResponse("""{"itemSummaries":[]}"""));
+ });
+ var tokenCache = new EbayTokenCache();
+ var service = CreateService(handler, cache.Create(), tokenCache);
+
+ await service.SearchUsedListingsAsync("lamp", null, null);
+ await service.SearchUsedListingsAsync("chair", null, null);
+
+ Assert.Equal(1, tokenRequests);
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_EmptyResponseReturnsNoListings()
+ {
+ using var cache = new TemporarySearchCache();
+ var service = CreateService(
+ new CallbackHandler((request, _) =>
+ Task.FromResult(
+ JsonResponse(
+ request.RequestUri!.AbsolutePath.Contains("oauth2/token")
+ ? TokenResponse
+ : "{}"
+ )
+ )
+ ),
+ cache.Create(),
+ new EbayTokenCache()
+ );
+
+ var listings = await service.SearchUsedListingsAsync("lamp", null, null);
+
+ Assert.Empty(listings);
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_SearchApiErrorThrows()
+ {
+ using var cache = new TemporarySearchCache();
+ var service = CreateService(
+ new CallbackHandler((request, _) =>
+ Task.FromResult(
+ request.RequestUri!.AbsolutePath.Contains("oauth2/token")
+ ? JsonResponse(TokenResponse)
+ : new HttpResponseMessage(HttpStatusCode.Unauthorized)
+ {
+ Content = new StringContent(
+ """{"errors":[{"message":"Invalid access token"}]}""",
+ Encoding.UTF8,
+ "application/json"
+ ),
+ }
+ )
+ ),
+ cache.Create(),
+ new EbayTokenCache()
+ );
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.SearchUsedListingsAsync("lamp", null, null)
+ );
+
+ Assert.Contains("401", exception.Message);
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_TokenRequestFailureThrowsWithoutExposingTheSecret()
+ {
+ using var cache = new TemporarySearchCache();
+ var service = CreateService(
+ new CallbackHandler(
+ (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized))
+ ),
+ cache.Create(),
+ new EbayTokenCache(),
+ new EbayOptions
+ {
+ ClientId = "test-client-id",
+ ClientSecret = "super-secret-value",
+ }
+ );
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.SearchUsedListingsAsync("lamp", null, null)
+ );
+
+ Assert.DoesNotContain("super-secret-value", exception.ToString());
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_MissingCredentialsFailsWithoutCallingProvider()
+ {
+ using var cache = new TemporarySearchCache();
+ var handler = new CallbackHandler(
+ (_, _) => throw new InvalidOperationException("Unexpected request.")
+ );
+ var service = CreateService(
+ handler,
+ cache.Create(),
+ new EbayTokenCache(),
+ new EbayOptions()
+ );
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.SearchUsedListingsAsync("lamp", null, null)
+ );
+
+ Assert.Contains("credentials are not configured", exception.Message);
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_ReusesDiskCacheForEquivalentQueriesAcrossServiceInstances()
+ {
+ using var cache = new TemporarySearchCache();
+ var requestCount = 0;
+ var firstService = CreateService(
+ new CallbackHandler((request, _) =>
+ {
+ requestCount++;
+ return Task.FromResult(
+ JsonResponse(
+ request.RequestUri!.AbsolutePath.Contains("oauth2/token")
+ ? TokenResponse
+ : """{"itemSummaries":[{"title":"Used Fender CD-60S guitar","price":{"value":"130.00","currency":"USD"},"conditionId":"3000"}]}"""
+ )
+ );
+ }),
+ cache.Create(),
+ new EbayTokenCache()
+ );
+ var first = await firstService.SearchUsedListingsAsync("acoustic guitar", "Fender", "CD-60S");
+
+ // A new cache object simulates an app restart while reading the same persistent
+ // directory; a fresh token cache too, since it is per-process in production.
+ var secondService = CreateService(
+ new CallbackHandler(
+ (_, _) => throw new InvalidOperationException("A cached search should not call eBay.")
+ ),
+ cache.Create(),
+ new EbayTokenCache()
+ );
+ var second = await secondService.SearchUsedListingsAsync(
+ " ACOUSTIC GUITAR ",
+ "fender",
+ "cd-60s"
+ );
+
+ Assert.Equal(2, requestCount); // one token fetch + one search, both on the first service
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public async Task SearchUsedListingsAsync_CoalescesConcurrentIdenticalQueries()
+ {
+ using var cache = new TemporarySearchCache();
+ var sharedSearchCache = cache.Create();
+ var sharedTokenCache = new EbayTokenCache();
+ var searchRequests = 0;
+ var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var firstService = CreateService(
+ new CallbackHandler(async (request, cancellationToken) =>
+ {
+ if (request.RequestUri!.AbsolutePath.Contains("oauth2/token"))
+ {
+ return JsonResponse(TokenResponse);
+ }
+
+ Interlocked.Increment(ref searchRequests);
+ requestStarted.SetResult();
+ await releaseRequest.Task.WaitAsync(cancellationToken);
+ return JsonResponse("""{"itemSummaries":[]}""");
+ }),
+ sharedSearchCache,
+ sharedTokenCache
+ );
+ var secondService = CreateService(
+ new CallbackHandler((request, _) =>
+ request.RequestUri!.AbsolutePath.Contains("oauth2/token")
+ ? Task.FromResult(JsonResponse(TokenResponse))
+ : throw new InvalidOperationException("A duplicate search was sent.")
+ ),
+ sharedSearchCache,
+ sharedTokenCache
+ );
+
+ var firstTask = firstService.SearchUsedListingsAsync("lamp", null, null);
+ await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var secondTask = secondService.SearchUsedListingsAsync("lamp", null, null);
+ releaseRequest.SetResult();
+ var results = await Task.WhenAll(firstTask, secondTask);
+
+ Assert.Equal(1, searchRequests);
+ Assert.Empty(results[0]);
+ Assert.Empty(results[1]);
+ }
+
+ private static EbayMarketDataService CreateService(
+ HttpMessageHandler handler,
+ MarketDataSearchCache cache,
+ EbayTokenCache tokenCache,
+ EbayOptions? options = null
+ ) =>
+ new(
+ new HttpClient(handler),
+ Options.Create(
+ options
+ ?? new EbayOptions
+ {
+ ClientId = "test-client-id",
+ ClientSecret = "test-client-secret",
+ }
+ ),
+ cache,
+ tokenCache
+ );
+
+ private static HttpResponseMessage JsonResponse(string body) =>
+ new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+
+ private sealed class CallbackHandler(
+ Func> callback
+ ) : HttpMessageHandler
+ {
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ ) => callback(request, cancellationToken);
+ }
+
+ private sealed class TemporarySearchCache : IDisposable
+ {
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(),
+ $"MoneyMirror-EbaySearchCacheTests-{Guid.NewGuid():N}"
+ );
+
+ public MarketDataSearchCache Create() => new(_directory);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ }
+ }
+
+ private sealed record RequestSnapshot(
+ HttpMethod Method,
+ Uri Uri,
+ string? Authorization,
+ string? MarketplaceId,
+ string Body
+ )
+ {
+ public static async Task CaptureAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ ) =>
+ new(
+ request.Method,
+ request.RequestUri!,
+ request.Headers.Authorization?.ToString(),
+ request.Headers.TryGetValues("X-EBAY-C-MARKETPLACE-ID", out var values)
+ ? values.FirstOrDefault()
+ : null,
+ request.Content is null
+ ? string.Empty
+ : await request.Content.ReadAsStringAsync(cancellationToken)
+ );
+ }
+}
diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs
index 5fa1b17..6a7d725 100644
--- a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs
+++ b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs
@@ -21,7 +21,9 @@ public EfPhysicalAssetRepositoryTests()
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
- var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options;
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
_db = new MoneyMirrorDbContext(options);
_db.Database.EnsureCreated();
@@ -37,7 +39,9 @@ public void Dispose()
[Fact]
public async Task AddAsync_WithInitialValuation_IsReturnedByGetAllWithThatValuation()
{
- await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m));
+ await _repository.AddAsync(
+ new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)
+ );
var all = await _repository.GetAllAsync();
@@ -62,9 +66,14 @@ public async Task AddAsync_WithoutValuation_HasNullCurrentValuation()
[Fact]
public async Task UpdateAsync_ExistingItem_ChangesItsFields()
{
- var id = await _repository.AddAsync(new PhysicalAssetInput("Old Name", "Old Category", null, null));
+ var id = await _repository.AddAsync(
+ new PhysicalAssetInput("Old Name", "Old Category", null, null)
+ );
- var updated = await _repository.UpdateAsync(id, new PhysicalAssetInput("New Name", "New Category", "Model X", null));
+ var updated = await _repository.UpdateAsync(
+ id,
+ new PhysicalAssetInput("New Name", "New Category", "Model X", null)
+ );
Assert.True(updated);
var asset = Assert.Single(await _repository.GetAllAsync());
@@ -87,7 +96,10 @@ public async Task UpdateAsync_WithNewValuation_UpdatesCurrentValuation()
[Fact]
public async Task UpdateAsync_UnknownId_ReturnsFalse()
{
- var updated = await _repository.UpdateAsync(Guid.NewGuid(), new PhysicalAssetInput("X", null, null, null));
+ var updated = await _repository.UpdateAsync(
+ Guid.NewGuid(),
+ new PhysicalAssetInput("X", null, null, null)
+ );
Assert.False(updated);
}
@@ -129,7 +141,15 @@ public async Task GetAllAsync_MultipleItems_SumsToCorrectTotalValue()
public async Task AddFromScanAsync_CreatesScannedItemWithValuationAndEvidence()
{
var id = await _repository.AddFromScanAsync(
- new ScannedAssetInput("Guitar", "Music", "Fender CD-60S", "abc123.jpg", 120m, "Typical used price."));
+ new ScannedAssetInput(
+ "Guitar",
+ "Music",
+ "Fender CD-60S",
+ "abc123.jpg",
+ 120m,
+ "Typical used price."
+ )
+ );
var summary = Assert.Single(await _repository.GetAllAsync());
Assert.Equal(id, summary.Id);
@@ -172,7 +192,12 @@ public async Task AddValuationAsync_ExistingItem_AppendsToHistoryWithoutRemoving
{
var id = await _repository.AddAsync(new PhysicalAssetInput("Guitar", null, null, 100m));
- var updated = await _repository.AddValuationAsync(id, 175m, "AI estimate (revalue)", "Prices went up.");
+ var updated = await _repository.AddValuationAsync(
+ id,
+ 175m,
+ "AI estimate (revalue)",
+ "Prices went up."
+ );
Assert.True(updated);
var detail = await _repository.GetByIdAsync(id);
@@ -189,7 +214,12 @@ public async Task AddValuationAsync_ExistingItem_AppendsToHistoryWithoutRemoving
[Fact]
public async Task AddValuationAsync_UnknownId_ReturnsFalse()
{
- var updated = await _repository.AddValuationAsync(Guid.NewGuid(), 100m, "AI estimate", null);
+ var updated = await _repository.AddValuationAsync(
+ Guid.NewGuid(),
+ 100m,
+ "AI estimate",
+ null
+ );
Assert.False(updated);
}
@@ -201,12 +231,22 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid
{
IReadOnlyList comps =
[
- new(115m, "eBay", "Fender CD-60S, used", "Good"),
- new(130m, "eBay", "Fender CD-60S dreadnought", null),
+ new(115m, "Reverb", "Fender CD-60S, used", "Good"),
+ new(130m, "Marketplace", "Fender CD-60S dreadnought", null),
];
var id = await _repository.AddFromScanAsync(
- new ScannedAssetInput("Guitar", "Music", "Fender CD-60S", "abc123.jpg", 122.5m, "Median of 2 listings.", "Market evidence", comps));
+ new ScannedAssetInput(
+ "Guitar",
+ "Music",
+ "Fender CD-60S",
+ "abc123.jpg",
+ 122.5m,
+ "Median of 2 listings.",
+ "Market evidence",
+ comps
+ )
+ );
var detail = await _repository.GetByIdAsync(id);
@@ -214,7 +254,7 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid
var entry = Assert.Single(detail!.ValuationHistory);
Assert.Equal(2, entry.ComparableListings.Count);
Assert.Equal(115m, entry.ComparableListings[0].PriceUsd);
- Assert.Equal("eBay", entry.ComparableListings[0].Source);
+ Assert.Equal("Reverb", entry.ComparableListings[0].Source);
Assert.Equal("Fender CD-60S, used", entry.ComparableListings[0].ListingTitle);
Assert.Equal("Good", entry.ComparableListings[0].Condition);
Assert.Null(entry.ComparableListings[1].Condition);
@@ -223,11 +263,25 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid
[Fact]
public async Task AddValuationAsync_RevalueWithNewEvidence_DoesNotAffectPriorEntrysEvidence()
{
- IReadOnlyList firstComps = [new(100m, "eBay", null, null)];
- IReadOnlyList secondComps = [new(140m, "eBay", null, null), new(160m, "Craigslist", null, null)];
+ IReadOnlyList firstComps = [new(100m, "Reverb", null, null)];
+ IReadOnlyList secondComps =
+ [
+ new(140m, "Marketplace", null, null),
+ new(160m, "Craigslist", null, null),
+ ];
var id = await _repository.AddFromScanAsync(
- new ScannedAssetInput("Guitar", null, null, null, 100m, "First estimate.", "Market evidence", firstComps));
+ new ScannedAssetInput(
+ "Guitar",
+ null,
+ null,
+ null,
+ 100m,
+ "First estimate.",
+ "Market evidence",
+ firstComps
+ )
+ );
await _repository.AddValuationAsync(id, 150m, "Market evidence", "Revalued.", secondComps);
var detail = await _repository.GetByIdAsync(id);
@@ -257,9 +311,14 @@ public async Task AddValuationAsync_WithoutComparableListings_LeavesEvidenceEmpt
[Fact]
public async Task FindPossibleDuplicatesAsync_MatchingProductModel_ReturnsExistingItem()
{
- await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m));
+ await _repository.AddAsync(
+ new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)
+ );
- var duplicates = await _repository.FindPossibleDuplicatesAsync("Acoustic guitar", "fender cd-60s");
+ var duplicates = await _repository.FindPossibleDuplicatesAsync(
+ "Acoustic guitar",
+ "fender cd-60s"
+ );
var duplicate = Assert.Single(duplicates);
Assert.Equal("Guitar", duplicate.Name);
@@ -278,7 +337,9 @@ public async Task FindPossibleDuplicatesAsync_MatchingNameOnly_ReturnsExistingIt
[Fact]
public async Task FindPossibleDuplicatesAsync_NoMatch_ReturnsEmpty()
{
- await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m));
+ await _repository.AddAsync(
+ new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)
+ );
var duplicates = await _repository.FindPossibleDuplicatesAsync("Lamp", "IKEA Foto");
@@ -288,7 +349,9 @@ public async Task FindPossibleDuplicatesAsync_NoMatch_ReturnsEmpty()
[Fact]
public async Task FindPossibleDuplicatesAsync_DifferentProductModelButSameName_StillMatchesOnName()
{
- await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m));
+ await _repository.AddAsync(
+ new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)
+ );
var duplicates = await _repository.FindPossibleDuplicatesAsync("Guitar", "Gibson Les Paul");
diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs
new file mode 100644
index 0000000..998e590
--- /dev/null
+++ b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs
@@ -0,0 +1,162 @@
+using MoneyMirror.Ai;
+using MoneyMirror.PhysicalAssets;
+
+namespace MoneyMirror.Tests.PhysicalAssets;
+
+public class EvidenceBasedAssetValuationServiceTests
+{
+ private static readonly DateTimeOffset ValuationDate = new(
+ 2026,
+ 9,
+ 20,
+ 12,
+ 0,
+ 0,
+ TimeSpan.Zero
+ );
+
+ [Fact]
+ public async Task EstimateAsync_UsesMarketListingsAndReturnsStructuredEvidence()
+ {
+ var source = new FakeMarketDataService([
+ new(80m, "Reverb", "Used item A", "Used"),
+ new(100m, "Craigslist", "Used item B", "Used"),
+ new(140m, "Marketplace", "Used item C", "Used"),
+ ]);
+ var service = new EvidenceBasedAssetValuationService(
+ source,
+ UnusedFallback(),
+ new FixedTimeProvider(ValuationDate)
+ );
+
+ var valuation = await service.EstimateAsync("guitar", "Fender", "CD-60S");
+
+ Assert.Equal(100m, valuation.EstimatedValueUsd);
+ Assert.Equal(ValuationDate, valuation.ValuationDate);
+ Assert.False(valuation.IsAiEstimated);
+ Assert.False(valuation.IsLowConfidence);
+ Assert.Equal(3, valuation.Evidence.Count);
+ Assert.Equal("guitar", source.LastLabel);
+ Assert.Equal("Fender", source.LastBrand);
+ Assert.Equal("CD-60S", source.LastModel);
+ }
+
+ [Fact]
+ public async Task EstimateAsync_NoListingsReturnsExplicitUnavailableLowConfidenceResult()
+ {
+ var source = new FakeMarketDataService([]);
+ var service = new EvidenceBasedAssetValuationService(
+ source,
+ UnusedFallback(),
+ new FixedTimeProvider(ValuationDate)
+ );
+
+ var valuation = await service.EstimateAsync("rare item", null, null);
+
+ Assert.Null(valuation.EstimatedValueUsd);
+ Assert.Empty(valuation.Evidence);
+ Assert.False(valuation.IsAiEstimated);
+ Assert.True(valuation.IsLowConfidence);
+ Assert.Contains("no usable comparable listings", valuation.Reasoning);
+ }
+
+ [Fact]
+ public async Task EstimateAsync_ProviderFailureFallsBackToAiEstimate()
+ {
+ var source = new FakeMarketDataService(
+ new EbayMarketDataException("provider unavailable")
+ );
+ var fallback = new AiEstimatedValuationService(
+ new FakeLlmService(
+ """{"estimatedValueUsd": 75.00, "reasoning": "A typical used guitar in this condition sells for about $75."}"""
+ ),
+ new FixedTimeProvider(ValuationDate)
+ );
+ var service = new EvidenceBasedAssetValuationService(
+ source,
+ fallback,
+ new FixedTimeProvider(ValuationDate)
+ );
+
+ var valuation = await service.EstimateAsync("guitar", null, null);
+
+ Assert.Equal(75.00m, valuation.EstimatedValueUsd);
+ Assert.True(valuation.IsAiEstimated);
+ Assert.Equal("AI estimate (low confidence; not based on live market data)", valuation.SourceLabel);
+ Assert.Empty(valuation.Evidence);
+ }
+
+ [Fact]
+ public async Task EstimateAsync_ProviderFailureAndFallbackFailureThrowsTheFallbacksException()
+ {
+ var source = new FakeMarketDataService(
+ new EbayMarketDataException("provider unavailable")
+ );
+ var fallback = new AiEstimatedValuationService(
+ new ThrowingLlmService(),
+ new FixedTimeProvider(ValuationDate)
+ );
+ var service = new EvidenceBasedAssetValuationService(
+ source,
+ fallback,
+ new FixedTimeProvider(ValuationDate)
+ );
+
+ await Assert.ThrowsAsync(() =>
+ service.EstimateAsync("guitar", null, null)
+ );
+ }
+
+ private sealed class FakeMarketDataService(IReadOnlyList result)
+ : IMarketDataService
+ {
+ private readonly Exception? _exception = null;
+
+ public FakeMarketDataService(Exception exception)
+ : this([])
+ {
+ _exception = exception;
+ }
+
+ public string? LastLabel { get; private set; }
+ public string? LastBrand { get; private set; }
+ public string? LastModel { get; private set; }
+
+ public Task> SearchUsedListingsAsync(
+ string label,
+ string? brand,
+ string? model,
+ CancellationToken cancellationToken = default
+ )
+ {
+ LastLabel = label;
+ LastBrand = brand;
+ LastModel = model;
+ return _exception is null
+ ? Task.FromResult(result)
+ : Task.FromException>(_exception);
+ }
+ }
+
+ private sealed class FakeLlmService(string completion) : ILlmService
+ {
+ public Task CompleteAsync(string prompt, CancellationToken cancellationToken = default) =>
+ Task.FromResult(completion);
+ }
+
+ private sealed class ThrowingLlmService : ILlmService
+ {
+ public Task CompleteAsync(string prompt, CancellationToken cancellationToken = default) =>
+ Task.FromException(new LlmServiceException("The LLM is unavailable."));
+ }
+
+ // A fallback that must never actually be called - used by tests where the market
+ // data provider succeeds, so the fallback path should never execute.
+ private static AiEstimatedValuationService UnusedFallback() =>
+ new(new ThrowingLlmService());
+
+ private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ }
+}