diff --git a/.env.example b/.env.example
index ddaa166..ffd2245 100644
--- a/.env.example
+++ b/.env.example
@@ -33,7 +33,20 @@ JWT_AUDIENCE=LudosData
CORS_ORIGIN=http://localhost:8080
# --- Market value (optional) ------------------------------------------------
-# Prices come from eBay's Browse API, which needs a free developer account.
+# Three routes, none of which block the others. Nothing here is required: with
+# all of it blank, prices can still be imported as a CSV or typed in by hand.
+#
+# 1. CSV PRICE GUIDE — no account, works immediately.
+# POST a CSV to /api/prices/import. Columns are matched by name, so a
+# PriceCharting bulk download, a spreadsheet you maintain, or any other list
+# all work. Nothing to configure here.
+#
+# 2. PRICECHARTING — paid, but access is immediate with no review, which makes
+# it the practical choice while an eBay keyset is in verification. Token
+# comes from the Subscriptions page, "API/Download" button.
+PRICECHARTING_TOKEN=
+
+# 3. EBAY BROWSE — free, but the production keyset needs account verification.
#
# 1. Register at https://developer.ebay.com and create a developer account
# 2. Create an application keyset (Application Keys -> Production)
diff --git a/README.md b/README.md
index dea4c7e..401315b 100644
--- a/README.md
+++ b/README.md
@@ -216,43 +216,52 @@ keys: `rating`, `value`, `price`, `purchased`.
### Market value
-Prices come from **eBay's Browse API**, which needs a free developer account:
-register at developer.ebay.com, create a production application keyset, and put
-the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`.
-Without them the pricing endpoints answer 503 with an explanation and nothing
-else is affected.
+Three routes, deliberately independent, because each is blocked differently.
+
+| Route | Blocked by | Cost | Basis |
+| --- | --- | --- | --- |
+| **CSV price guide** | nothing | free | whatever you supply |
+| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
+| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
+| Manual entry | nothing | free | your own judgement |
```
-GET /api/prices/status is a provider configured?
-POST /api/prices/refresh {dryRun, limit} price some games
+GET /api/prices/status which sources are usable
+POST /api/prices/refresh {provider, dryRun} price from a live source
+POST /api/prices/import (multipart CSV) apply a price list
```
-**These are asking prices, not sold prices.** Browse returns active listings.
-eBay's completed-sales data lives behind the Marketplace Insights API, which is
-a limited release closed to new developers, and PriceCharting — the usual
-alternative — requires a paid subscription. Asking prices skew high: sellers
-list optimistically and unsold listings linger. Treat the numbers as an upper
-bound. The provider name (`ebay-asking`) is stored with every value it writes,
-so the source is always visible next to the figure.
+**The CSV route needs no account and works today.** Column names are matched by
+alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
+export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
+are `$`, thousands separators and blank cells. Rows are matched on title +
+system, so the same game on two consoles is priced separately; rows for games
+you do not own are reported rather than added.
-Deriving a price from listings takes more than an average:
+Free sources that do **not** work for this: eBay's completed-sales data sits
+behind the Marketplace Insights API, a limited release closed to new
+developers; NEXARDA and CheapShark price current retail and digital
+storefronts, not collectibles. Scraping PriceCharting violates their terms.
-- **Listings are classified into loose / CIB / new** from the title, because a
- feed of mixed conditions has no single price. Accessories are discarded
- outright — a "box only" listing at $45 counted as a copy would halve the loose
- estimate for a $130 cartridge. So are reproductions and multi-game lots.
-- **The qualifier is required when discarding.** An early version matched a bare
- "box", which threw away "complete in box" and "with box and manual" — most of
- the CIB tier — while keeping the cheap box-only listings the filter existed to
- remove. Caught by a test asserting on the tier, not on the count.
-- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags
- a mean past the point of usefulness; a median ignores them.
-- **Sample counts travel with the estimate.** A tier from two listings deserves
- less confidence than one from thirty.
+Deriving a price from eBay listings takes more than an average:
+
+- **Listings are classified into loose / CIB / new** from their titles, since a
+ mixed feed has no single price. Accessories are discarded — a "box only"
+ listing at $45 counted as a copy would halve the loose estimate for a $130
+ cartridge — as are reproductions and multi-game lots.
+- **The discard qualifier is required.** An early version matched a bare "box",
+ which threw away "complete in box" and "with box and manual" — most of the CIB
+ tier — while keeping exactly the listings the filter existed to remove. Caught
+ by a test asserting on tiers rather than counts.
+- **Median with an interquartile trim.** One optimist asking 50x moves a mean
+ and not a median.
+- **Sample counts travel with the estimate**, because a tier drawn from two
+ listings deserves less confidence than one drawn from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches
-that copy's condition. Changing a game's condition re-prices it from the stored
-tiers without another lookup.
+that copy's condition — so changing a condition re-prices it with no further
+lookup. Every value carries the source that wrote it and the moment it was
+captured.
### Database changes
diff --git a/backend/src/LudosData.Api/Controllers/PricesController.cs b/backend/src/LudosData.Api/Controllers/PricesController.cs
index 7f90833..d665e12 100644
--- a/backend/src/LudosData.Api/Controllers/PricesController.cs
+++ b/backend/src/LudosData.Api/Controllers/PricesController.cs
@@ -1,5 +1,7 @@
+using System.Text;
using LudosData.Api.Auth;
using LudosData.Api.Data;
+using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -9,6 +11,9 @@ namespace LudosData.Api.Controllers;
public record PriceRefreshRequest
{
+ /// Which source to price from. Defaults to the first configured one.
+ public string? Provider { get; init; }
+
/// Limit the run to specific games. Empty means the whole library.
public List? GameIds { get; init; }
@@ -23,58 +28,75 @@ public record PriceRefreshRequest
}
public record PriceRefreshItem(
- int GameId,
- string Title,
- decimal? Loose,
- decimal? Cib,
- decimal? New,
- int Samples,
- int Discarded,
- string? Error);
+ int GameId, string Title,
+ decimal? Loose, decimal? Cib, decimal? New,
+ int Samples, int Discarded, string? Error);
public record PriceRefreshResult(
- bool DryRun,
- string Source,
- int Considered,
- int Priced,
- int Failed,
+ bool DryRun, string Source, int Considered, int Priced, int Failed,
IReadOnlyList Items);
+public record ProviderStatus(string Name, bool Configured, string Basis, string? Setup);
+
+public record PriceImportResult(
+ bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
+ IReadOnlyList UnmatchedTitles, IReadOnlyList Problems);
+
///
-/// Refreshes market values from a price provider.
+/// Market values, from whichever sources are configured.
///
-/// The figures are asking prices from active listings, not completed sales —
-/// see — so they read high. The provider name
-/// travels with every value it writes, so the dashboard can say where a number
-/// came from and how old it is.
+/// Several can coexist because each is blocked in a different way: eBay is free
+/// but its production keyset needs account verification, PriceCharting is
+/// immediate but paid, and a CSV price guide needs neither. The provider name
+/// travels with every value written, so a figure always says where it came from.
///
[ApiController]
[Route("api/prices")]
[Authorize]
public class PricesController(
LudosDbContext db,
- IPriceProvider provider,
+ IEnumerable providers,
ILogger logger) : ControllerBase
{
- [HttpGet("status")]
- public IActionResult Status() => Ok(new
+ private static readonly Dictionary ProviderNotes = new()
{
- provider = provider.Name,
- configured = provider.IsConfigured,
- // Stated plainly so a caller cannot mistake these for sold prices.
- basis = "active listing asking prices, not completed sales",
- });
+ ["ebay-asking"] = (
+ "active listing asking prices, not completed sales — expect these to read high",
+ "Free, but the production keyset needs eBay account verification. "
+ + "Set EBAY_CLIENT_ID and EBAY_CLIENT_SECRET."),
+ ["pricecharting"] = (
+ "sale-derived prices quoted per condition",
+ "Paid subscription, but access is immediate with no review. "
+ + "Set PRICECHARTING_TOKEN."),
+ };
+
+ [HttpGet("status")]
+ public ActionResult> Status() => Ok(providers.Select(p =>
+ {
+ var notes = ProviderNotes.TryGetValue(p.Name, out var n)
+ ? n
+ : ("unspecified", "see documentation");
+
+ return new ProviderStatus(p.Name, p.IsConfigured, notes.Item1,
+ p.IsConfigured ? null : notes.Item2);
+ }));
[HttpPost("refresh")]
public async Task> Refresh(
PriceRefreshRequest request, CancellationToken ct)
{
- if (!provider.IsConfigured)
+ var provider = ResolveProvider(request.Provider);
+
+ if (provider is null)
{
+ var available = string.Join(", ", providers.Select(p => p.Name));
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
- Title = "No price provider is configured.",
- Detail = "Set Ebay:ClientId and Ebay:ClientSecret, then restart the API.",
+ Title = request.Provider is null
+ ? "No price provider is configured."
+ : $"Price provider '{request.Provider}' is not configured.",
+ Detail = $"Known providers: {available}. Configure one, or import a price "
+ + "guide CSV at POST /api/prices/import, which needs no account.",
});
}
@@ -112,12 +134,7 @@ public class PricesController(
if (!request.DryRun)
{
- game.ValueLoose = estimate.Loose;
- game.ValueCib = estimate.Cib;
- game.ValueNew = estimate.New;
- game.RecalculateEffectiveValue();
- game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
- game.MarketValueSource = provider.Name;
+ ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
}
priced++;
@@ -145,4 +162,108 @@ public class PricesController(
return Ok(new PriceRefreshResult(
request.DryRun, provider.Name, games.Count, priced, failed, items));
}
+
+ ///
+ /// Applies an external price list.
+ ///
+ /// This is the path that needs no account and no approval: export a guide
+ /// from wherever you have one, or keep a spreadsheet, and the prices land on
+ /// the matching games.
+ ///
+ [HttpPost("import")]
+ [RequestSizeLimit(32 * 1024 * 1024)]
+ public async Task> Import(
+ IFormFile file,
+ [FromQuery] string source = "price-guide",
+ [FromQuery] bool dryRun = false,
+ CancellationToken ct = default)
+ {
+ if (file is null || file.Length == 0)
+ {
+ return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
+ }
+
+ string text;
+ using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, true))
+ {
+ text = await reader.ReadToEndAsync(ct);
+ }
+
+ var guide = PriceGuide.Parse(text);
+ if (guide.Rows.Count == 0)
+ {
+ return BadRequest(new ProblemDetails
+ {
+ Title = "No usable rows were found.",
+ Detail = string.Join(" ", guide.Problems),
+ });
+ }
+
+ var ownerId = User.GetUserId();
+ var games = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
+
+ // Same key as the library import: a game is a title on a platform, so
+ // three Donkey Kong Countrys stay three separately priced entries.
+ var index = games
+ .GroupBy(g => Key(g.Title, g.System))
+ .ToDictionary(g => g.Key, g => g.First());
+
+ int matched = 0, updated = 0;
+ var unmatched = new List();
+
+ foreach (var row in guide.Rows)
+ {
+ if (!index.TryGetValue(Key(row.Title, row.System), out var game))
+ {
+ unmatched.Add($"{row.Title}{(row.System is null ? "" : $" ({row.System})")}");
+ continue;
+ }
+
+ matched++;
+
+ if (row.Loose is null && row.Cib is null && row.New is null)
+ {
+ continue;
+ }
+
+ if (!dryRun)
+ {
+ ApplyEstimate(game, row.Loose, row.Cib, row.New, source);
+ }
+ updated++;
+ }
+
+ if (!dryRun && updated > 0)
+ {
+ await db.SaveChangesAsync(ct);
+ logger.LogInformation("User {OwnerId} priced {Count} games from a {Source} guide",
+ ownerId, updated, source);
+ }
+
+ return Ok(new PriceImportResult(
+ dryRun, guide.Rows.Count, matched, updated, unmatched.Count,
+ // Capped: a full guide can miss thousands of rows that are simply
+ // games this library does not contain.
+ unmatched.Take(50).ToList(), guide.Problems));
+ }
+
+ private IPriceProvider? ResolveProvider(string? name) =>
+ name is null
+ ? providers.FirstOrDefault(p => p.IsConfigured)
+ : providers.FirstOrDefault(p =>
+ string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.IsConfigured);
+
+ private static void ApplyEstimate(
+ Game game, decimal? loose, decimal? cib, decimal? boxed, string source)
+ {
+ game.ValueLoose = loose;
+ game.ValueCib = cib;
+ game.ValueNew = boxed;
+ game.RecalculateEffectiveValue();
+ game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
+ game.MarketValueSource = source;
+ }
+
+ private static string Key(string title, string? system) =>
+ $"{title.Trim().ToLowerInvariant()} {(system ?? string.Empty).Trim().ToLowerInvariant()}";
}
diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs
index 86d486d..c1ab04c 100644
--- a/backend/src/LudosData.Api/Program.cs
+++ b/backend/src/LudosData.Api/Program.cs
@@ -117,8 +117,20 @@ builder.Services.AddSingleton();
// Pricing. The provider is registered whether or not credentials are present;
// it reports IsConfigured so the endpoint can answer 503 with a useful message
// rather than the app failing to start without an optional integration.
-builder.Services.Configure(builder.Configuration.GetSection(EbayOptions.SectionName));
+// Providers are registered whether or not credentials are present; each reports
+// IsConfigured so the endpoint can explain what is missing rather than the app
+// refusing to start without an optional integration. Order is the fallback
+// order when no provider is named: PriceCharting first, since it quotes
+// sale-derived prices per condition, then eBay's asking prices.
+builder.Services.Configure(
+ builder.Configuration.GetSection(PriceChartingOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(EbayOptions.SectionName));
+
+builder.Services.AddHttpClient("pricecharting", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30));
+
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services
diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs
new file mode 100644
index 0000000..4dbb91d
--- /dev/null
+++ b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs
@@ -0,0 +1,162 @@
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+
+namespace LudosData.Api.Services.Pricing;
+
+public class PriceChartingOptions
+{
+ public const string SectionName = "PriceCharting";
+
+ ///
+ /// Subscription token, from the Subscriptions page of a PriceCharting
+ /// account (the "API/Download" button).
+ ///
+ public string Token { get; set; } = string.Empty;
+
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(Token);
+}
+
+///
+/// Prices from PriceCharting, which quotes loose, complete and new separately —
+/// the same three tiers this app stores, so no inference is needed and the
+/// numbers are sale-derived rather than asking prices.
+///
+/// It needs a paid subscription, but access is immediate on subscribing, with
+/// no application or review. That makes it the practical option when an eBay
+/// developer account is stuck in verification.
+///
+/// One caveat worth knowing when you first run it: PriceCharting's published
+/// API documentation is not reachable without an account, so the response shape
+/// below follows their widely-used convention — prices as integer pennies under
+/// hyphenated keys. is deliberately tolerant and is the only
+/// place to adjust if their field names differ from this.
+///
+public class PriceChartingProvider(
+ IHttpClientFactory httpClientFactory,
+ IOptions options,
+ ILogger logger) : IPriceProvider
+{
+ private readonly PriceChartingOptions _options = options.Value;
+
+ public string Name => "pricecharting";
+
+ public bool IsConfigured => _options.IsConfigured;
+
+ ///
+ /// PriceCharting keys its catalogue by console name, so the query carries
+ /// one. Their names are spelled out rather than abbreviated.
+ ///
+ internal static string ConsoleName(string? system) => (system ?? string.Empty).ToUpperInvariant() switch
+ {
+ "NES" => "nes",
+ "SNES" => "super nintendo",
+ "N64" => "nintendo 64",
+ "GC" => "gamecube",
+ "WII" => "wii",
+ "GB" => "gameboy",
+ "GBA" => "gameboy advance",
+ "DS" => "nintendo ds",
+ "PS1" => "playstation",
+ "PS2" => "playstation 2",
+ "PSP" => "psp",
+ "360" => "xbox 360",
+ _ => system?.ToLowerInvariant() ?? string.Empty,
+ };
+
+ internal static string BuildQuery(string title, string? system)
+ {
+ var console = ConsoleName(system);
+ return string.IsNullOrWhiteSpace(console) ? title : $"{console} {title}";
+ }
+
+ public async Task EstimateAsync(
+ string title, string? system, CancellationToken ct = default)
+ {
+ if (!IsConfigured)
+ {
+ throw new InvalidOperationException(
+ "PriceCharting is not configured. Set PriceCharting:Token.");
+ }
+
+ var client = httpClientFactory.CreateClient("pricecharting");
+ var url = "https://www.pricecharting.com/api/product"
+ + $"?t={Uri.EscapeDataString(_options.Token)}"
+ + $"&q={Uri.EscapeDataString(BuildQuery(title, system))}";
+
+ using var response = await client.GetAsync(url, ct);
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("PriceCharting lookup for {Title} returned {Status}",
+ title, response.StatusCode);
+ return PriceEstimate.Empty;
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
+ return Parse(document);
+ }
+
+ ///
+ /// Reads the three tiers from a product response.
+ ///
+ /// Internal so it can be tested on a fixture without a subscription, and
+ /// tolerant of both hyphenated and camelCase keys so a naming difference
+ /// degrades to "no price" rather than a crash.
+ ///
+ internal static PriceEstimate Parse(JsonDocument document)
+ {
+ var root = document.RootElement;
+
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return PriceEstimate.Empty;
+ }
+
+ // A miss is reported in-band with a status field rather than by HTTP.
+ if (root.TryGetProperty("status", out var status)
+ && string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
+ {
+ return PriceEstimate.Empty;
+ }
+
+ var loose = ReadPennies(root, "loose-price", "loosePrice");
+ var cib = ReadPennies(root, "cib-price", "cibPrice");
+ var boxed = ReadPennies(root, "new-price", "newPrice");
+
+ // A single quoted price per tier, so the sample count is one where a
+ // price exists — the estimate carries its own confidence either way.
+ return new PriceEstimate(
+ loose, cib, boxed,
+ loose is null ? 0 : 1,
+ cib is null ? 0 : 1,
+ boxed is null ? 0 : 1,
+ 0);
+ }
+
+ /// Prices arrive as integer pennies; 1250 means $12.50.
+ private static decimal? ReadPennies(JsonElement root, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!root.TryGetProperty(name, out var element))
+ {
+ continue;
+ }
+
+ long? pennies = element.ValueKind switch
+ {
+ JsonValueKind.Number when element.TryGetInt64(out var value) => value,
+ JsonValueKind.String when long.TryParse(element.GetString(), out var value) => value,
+ _ => null,
+ };
+
+ // Zero means "no price on record", not "free".
+ if (pennies is > 0)
+ {
+ return pennies.Value / 100m;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs b/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs
new file mode 100644
index 0000000..f4fb137
--- /dev/null
+++ b/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs
@@ -0,0 +1,136 @@
+using System.Globalization;
+
+using LudosData.Api.Services;
+
+namespace LudosData.Api.Services.Pricing;
+
+/// One row of an external price list.
+public record PriceGuideRow(
+ string Title,
+ string? System,
+ decimal? Loose,
+ decimal? Cib,
+ decimal? New);
+
+public record PriceGuideParseResult(
+ IReadOnlyList Rows,
+ IReadOnlyList Problems);
+
+///
+/// Reads a price list out of a CSV.
+///
+/// The point is to be source-agnostic. A PriceCharting subscriber's bulk
+/// download, a spreadsheet kept by hand, and a list exported from anywhere else
+/// all describe the same thing — a title, a platform and some prices — so this
+/// accepts the column names each of them tends to use rather than demanding one
+/// fixed schema.
+///
+public static class PriceGuide
+{
+ // Header aliases, lowercased and stripped of spaces, underscores and hyphens.
+ private static readonly string[] TitleNames =
+ ["title", "productname", "product", "name", "game", "gamename"];
+
+ private static readonly string[] SystemNames =
+ ["system", "console", "consolename", "platform"];
+
+ private static readonly string[] LooseNames =
+ ["loose", "looseprice", "loosevalue", "cartonly", "value"];
+
+ private static readonly string[] CibNames =
+ ["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"];
+
+ private static readonly string[] NewNames =
+ ["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"];
+
+ private static string Normalise(string header) =>
+ new(header.Trim().ToLowerInvariant()
+ .Where(c => char.IsLetterOrDigit(c))
+ .ToArray());
+
+ public static PriceGuideParseResult Parse(string text)
+ {
+ var problems = new List();
+ var rows = Csv.Parse(text);
+
+ if (rows.Count == 0)
+ {
+ return new PriceGuideParseResult([], ["The file is empty."]);
+ }
+
+ var header = rows[0].Select(Normalise).ToList();
+
+ int Find(string[] names)
+ {
+ foreach (var name in names)
+ {
+ var at = header.IndexOf(name);
+ if (at >= 0) return at;
+ }
+ return -1;
+ }
+
+ var titleAt = Find(TitleNames);
+ if (titleAt < 0)
+ {
+ return new PriceGuideParseResult([],
+ ["No title column found. Expected one of: title, product-name, name, game."]);
+ }
+
+ var systemAt = Find(SystemNames);
+ var looseAt = Find(LooseNames);
+ var cibAt = Find(CibNames);
+ var newAt = Find(NewNames);
+
+ if (looseAt < 0 && cibAt < 0 && newAt < 0)
+ {
+ return new PriceGuideParseResult([],
+ ["No price column found. Expected at least one of: loose, cib, new."]);
+ }
+
+ var parsed = new List();
+
+ for (var i = 1; i < rows.Count; i++)
+ {
+ var row = rows[i];
+
+ string? Cell(int at) =>
+ at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null;
+
+ var title = Cell(titleAt);
+ if (title is null)
+ {
+ problems.Add($"Row {i + 1}: no title");
+ continue;
+ }
+
+ parsed.Add(new PriceGuideRow(
+ title, Cell(systemAt),
+ ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt))));
+ }
+
+ return new PriceGuideParseResult(parsed, problems);
+ }
+
+ ///
+ /// Tolerant of what a spreadsheet emits: currency symbols, thousands
+ /// separators, and integer pennies where a guide quotes them that way.
+ ///
+ public static decimal? ParseMoney(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim();
+
+ if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed))
+ {
+ return null;
+ }
+
+ // Zero means "no price on record", not "free".
+ return parsed > 0 ? parsed : null;
+ }
+}
diff --git a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
new file mode 100644
index 0000000..db239d7
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
@@ -0,0 +1,369 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using LudosData.Api.Services.Pricing;
+
+namespace LudosData.Api.Tests;
+
+public class PriceGuideParsingTests
+{
+ [Fact]
+ public void A_plain_guide_is_read()
+ {
+ const string csv = """
+ title,console,loose,cib,new
+ Chrono Trigger,Super Nintendo,128.00,650.00,12000.00
+ Super Metroid,Super Nintendo,55.00,210.00,4000.00
+ """;
+
+ var result = PriceGuide.Parse(csv);
+
+ Assert.Equal(2, result.Rows.Count);
+ Assert.Empty(result.Problems);
+
+ var first = result.Rows[0];
+ Assert.Equal("Chrono Trigger", first.Title);
+ Assert.Equal("Super Nintendo", first.System);
+ Assert.Equal(128.00m, first.Loose);
+ Assert.Equal(650.00m, first.Cib);
+ Assert.Equal(12000.00m, first.New);
+ }
+
+ [Theory]
+ // Different exports name the same columns differently; all of these mean
+ // the same thing, and demanding one schema would make the feature useless
+ // for whichever guide the user actually has.
+ [InlineData("product-name,console-name,loose-price,cib-price,new-price")]
+ [InlineData("Product Name,Console Name,Loose Price,CIB Price,New Price")]
+ [InlineData("game,platform,loose,complete,sealed")]
+ [InlineData("NAME,SYSTEM,LOOSE,CIB,NEW")]
+ public void Column_aliases_and_casing_are_accepted(string header)
+ {
+ var result = PriceGuide.Parse($"{header}\nChrono Trigger,SNES,128.00,650.00,12000.00");
+
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("Chrono Trigger", row.Title);
+ Assert.Equal("SNES", row.System);
+ Assert.Equal(128.00m, row.Loose);
+ }
+
+ [Theory]
+ [InlineData("$128.00", 128.00)]
+ [InlineData("1,234.56", 1234.56)]
+ [InlineData(" $1,234.56 ", 1234.56)]
+ [InlineData("128", 128)]
+ public void Spreadsheet_formatting_is_tolerated(string input, double expected)
+ {
+ Assert.Equal((decimal)expected, PriceGuide.ParseMoney(input));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("0")]
+ [InlineData("0.00")]
+ [InlineData("n/a")]
+ public void Blank_and_zero_prices_read_as_no_price(string input)
+ {
+ // Zero means "not on record", and treating it as free would drag a
+ // collection total toward nothing.
+ Assert.Null(PriceGuide.ParseMoney(input));
+ }
+
+ [Fact]
+ public void A_guide_with_no_title_column_is_rejected_with_a_reason()
+ {
+ var result = PriceGuide.Parse("foo,bar\n1,2");
+
+ Assert.Empty(result.Rows);
+ Assert.Contains("title", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void A_guide_with_no_price_column_is_rejected_with_a_reason()
+ {
+ var result = PriceGuide.Parse("title,console\nChrono Trigger,SNES");
+
+ Assert.Empty(result.Rows);
+ Assert.Contains("price", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void A_partial_guide_is_usable()
+ {
+ // Plenty of lists quote one condition only.
+ var result = PriceGuide.Parse("title,system,loose\nChrono Trigger,SNES,128.00");
+
+ var row = Assert.Single(result.Rows);
+ Assert.Equal(128.00m, row.Loose);
+ Assert.Null(row.Cib);
+ Assert.Null(row.New);
+ }
+
+ [Fact]
+ public void Quoted_titles_containing_commas_survive()
+ {
+ var result = PriceGuide.Parse("title,system,loose\n\"Spyro 2: Ripto's Rage!, The Best\",PS1,40.00");
+
+ Assert.Equal("Spyro 2: Ripto's Rage!, The Best", Assert.Single(result.Rows).Title);
+ }
+}
+
+public class PriceChartingParsingTests
+{
+ [Fact]
+ public void Pennies_are_converted_to_currency()
+ {
+ using var document = JsonDocument.Parse("""
+ {
+ "status": "success",
+ "product-name": "Chrono Trigger",
+ "console-name": "Super Nintendo",
+ "loose-price": 12800,
+ "cib-price": 65000,
+ "new-price": 1200000
+ }
+ """);
+
+ var estimate = PriceChartingProvider.Parse(document);
+
+ Assert.Equal(128.00m, estimate.Loose);
+ Assert.Equal(650.00m, estimate.Cib);
+ Assert.Equal(12000.00m, estimate.New);
+ }
+
+ [Fact]
+ public void A_zero_price_is_treated_as_absent()
+ {
+ using var document = JsonDocument.Parse("""
+ { "status": "success", "loose-price": 12800, "cib-price": 0, "new-price": 0 }
+ """);
+
+ var estimate = PriceChartingProvider.Parse(document);
+
+ Assert.Equal(128.00m, estimate.Loose);
+ // Zero means no price on record, not a free game.
+ Assert.Null(estimate.Cib);
+ Assert.Null(estimate.New);
+ }
+
+ [Fact]
+ public void An_error_response_yields_no_prices()
+ {
+ using var document = JsonDocument.Parse("""{ "status": "error", "error-message": "not found" }""");
+
+ Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
+ }
+
+ [Fact]
+ public void Unexpected_field_names_degrade_to_no_price_rather_than_throwing()
+ {
+ // Their docs are not reachable without an account, so a naming
+ // difference has to be survivable.
+ using var document = JsonDocument.Parse("""{ "status": "success", "somethingElse": 1 }""");
+
+ Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
+ }
+
+ [Theory]
+ [InlineData("SNES", "super nintendo Chrono Trigger")]
+ [InlineData("N64", "nintendo 64 GoldenEye")]
+ [InlineData("360", "xbox 360 Halo 3")]
+ public void The_console_name_leads_the_query(string system, string expected)
+ {
+ var title = expected.Split(' ').Last() == "Trigger" ? "Chrono Trigger"
+ : expected.Contains("GoldenEye") ? "GoldenEye" : "Halo 3";
+
+ Assert.Equal(expected, PriceChartingProvider.BuildQuery(title, system));
+ }
+}
+
+public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture
+{
+ private static MultipartFormDataContent CsvContent(string body)
+ {
+ var content = new MultipartFormDataContent();
+ var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body));
+ part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/csv");
+ content.Add(part, "file", "guide.csv");
+ return content;
+ }
+
+ private static async Task SeedAsync(HttpClient client)
+ {
+ await client.PostJsonAsync("/api/games", new
+ {
+ title = "Chrono Trigger", system = "SNES", own = true, condition = "Cib",
+ });
+ await client.PostJsonAsync("/api/games", new
+ {
+ title = "Super Metroid", system = "SNES", own = true, condition = "Loose",
+ });
+ }
+
+ [Fact]
+ public async Task Status_lists_every_provider_and_how_to_enable_it()
+ {
+ var client = await factory.CreateUserClientAsync("price-status");
+
+ var statuses = await client.GetJsonAsync>("/api/prices/status");
+
+ Assert.Contains(statuses!, s => s.Name == "ebay-asking");
+ Assert.Contains(statuses!, s => s.Name == "pricecharting");
+ // None are configured in tests, so each explains what is missing.
+ Assert.All(statuses!, s =>
+ {
+ Assert.False(s.Configured);
+ Assert.False(string.IsNullOrWhiteSpace(s.Setup));
+ });
+ }
+
+ [Fact]
+ public async Task Refresh_without_a_configured_provider_points_at_the_csv_route()
+ {
+ var client = await factory.CreateUserClientAsync("price-none");
+
+ var response = await client.PostJsonAsync("/api/prices/refresh", new { limit = 1 });
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ var problem = await response.Content.ReadAsStringAsync();
+ Assert.Contains("/api/prices/import", problem);
+ }
+
+ [Fact]
+ public async Task A_price_guide_sets_tiers_and_the_effective_value_follows_condition()
+ {
+ var client = await factory.CreateUserClientAsync("price-import");
+ await SeedAsync(client);
+
+ const string csv = """
+ title,system,loose,cib,new
+ Chrono Trigger,SNES,128.00,650.00,12000.00
+ Super Metroid,SNES,55.00,210.00,4000.00
+ """;
+
+ var result = await (await client.PostAsync("/api/prices/import?source=my-guide", CsvContent(csv)))
+ .Content.ReadJsonAsync();
+
+ Assert.Equal(2, result!.Matched);
+ Assert.Equal(2, result.Updated);
+ Assert.Equal(0, result.Unmatched);
+
+ var page = await client.GetJsonAsync("/api/games?sort=title");
+ var chrono = page!.Items.Single(g => g.Title == "Chrono Trigger");
+ var metroid = page.Items.Single(g => g.Title == "Super Metroid");
+
+ Assert.Equal(650.00m, chrono.MarketValue); // CIB copy takes the CIB tier
+ Assert.Equal(55.00m, metroid.MarketValue); // loose copy takes the loose tier
+ Assert.Equal("my-guide", chrono.MarketValueSource);
+ Assert.NotNull(chrono.MarketValueUpdatedAt);
+ }
+
+ [Fact]
+ public async Task Rows_for_games_not_in_the_library_are_reported_not_created()
+ {
+ var client = await factory.CreateUserClientAsync("price-unmatched");
+ await SeedAsync(client);
+
+ const string csv = """
+ title,system,loose
+ Chrono Trigger,SNES,128.00
+ EarthBound,SNES,300.00
+ """;
+
+ var result = await (await client.PostAsync("/api/prices/import", CsvContent(csv)))
+ .Content.ReadJsonAsync();
+
+ Assert.Equal(1, result!.Matched);
+ Assert.Equal(1, result.Unmatched);
+ Assert.Contains("EarthBound", result.UnmatchedTitles[0]);
+
+ // A price guide prices what you own; it does not add to the collection.
+ var page = await client.GetJsonAsync("/api/games");
+ Assert.Equal(2, page!.Total);
+ }
+
+ [Fact]
+ public async Task The_same_title_on_two_systems_is_priced_separately()
+ {
+ var client = await factory.CreateUserClientAsync("price-platform");
+ await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "SNES", own = true });
+ await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "GBA", own = true });
+
+ const string csv = """
+ title,system,loose
+ Donkey Kong Country,SNES,25.00
+ Donkey Kong Country,GBA,18.00
+ """;
+
+ await client.PostAsync("/api/prices/import", CsvContent(csv));
+
+ var page = await client.GetJsonAsync("/api/games");
+ Assert.Equal(25.00m, page!.Items.Single(g => g.System == "SNES").MarketValue);
+ Assert.Equal(18.00m, page.Items.Single(g => g.System == "GBA").MarketValue);
+ }
+
+ [Fact]
+ public async Task Dry_run_reports_without_writing()
+ {
+ var client = await factory.CreateUserClientAsync("price-dry");
+ await SeedAsync(client);
+
+ var result = await (await client.PostAsync(
+ "/api/prices/import?dryRun=true",
+ CsvContent("title,system,loose\nChrono Trigger,SNES,128.00")))
+ .Content.ReadJsonAsync();
+
+ Assert.True(result!.DryRun);
+ Assert.Equal(1, result.Updated);
+
+ var page = await client.GetJsonAsync("/api/games?search=Chrono");
+ Assert.Null(page!.Items[0].MarketValue);
+ }
+
+ [Fact]
+ public async Task A_guide_never_reaches_another_users_library()
+ {
+ var alice = await factory.CreateUserClientAsync("price-alice");
+ var bob = await factory.CreateUserClientAsync("price-bob");
+ await SeedAsync(alice);
+ await SeedAsync(bob);
+
+ await bob.PostAsync("/api/prices/import", CsvContent("title,system,loose\nChrono Trigger,SNES,999.00"));
+
+ var alicePage = await alice.GetJsonAsync("/api/games?search=Chrono");
+ Assert.Null(alicePage!.Items[0].MarketValue);
+ }
+
+ [Fact]
+ public async Task An_unreadable_guide_is_rejected_with_a_reason()
+ {
+ var client = await factory.CreateUserClientAsync("price-bad");
+
+ var response = await client.PostAsync("/api/prices/import", CsvContent("foo,bar\n1,2"));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Contains("title", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Pricing_endpoints_require_a_token()
+ {
+ var anonymous = factory.CreateClient();
+
+ Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/prices/status")).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await anonymous.PostAsync("/api/prices/import", CsvContent("title,loose\nX,1"))).StatusCode);
+ }
+
+ private record ProviderStatusPayload(string Name, bool Configured, string Basis, string? Setup);
+ private record PriceImportPayload(
+ bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
+ List UnmatchedTitles, List Problems);
+ private record GamePayload(
+ int Id, string Title, string? System, decimal? MarketValue,
+ DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource,
+ decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
+ private record PagePayload(List Items, int Total);
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 9740cff..ab903c9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,7 @@ services:
# Optional market-value lookups. Blank means the pricing endpoints report
# 503 and everything else carries on.
+ PriceCharting__Token: ${PRICECHARTING_TOKEN:-}
Ebay__ClientId: ${EBAY_CLIENT_ID:-}
Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-}
Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false}