Add CSV price guides and PriceCharting alongside eBay

An eBay production keyset needs account verification, which leaves pricing
blocked on someone else's review queue. These are two routes that are not.

CSV price guide, POST /api/prices/import: no account, works immediately.
Column names are matched by alias, so a PriceCharting bulk export
(product-name / console-name / loose-price) and a hand-kept
title,system,loose,cib,new sheet both parse, along with currency symbols,
thousands separators and blank cells. Rows match on title + system, so the
same game on two consoles is priced separately, and rows for games not in
the library are reported rather than silently added.

PriceCharting adapter: paid, but access is immediate with no review, and it
quotes the same three tiers this app stores, so no inference is needed.
Their API docs are not reachable without an account, so the parser follows
the widely-used convention — integer pennies under hyphenated keys — and is
tolerant enough that a naming difference degrades to "no price" instead of
throwing. One method to adjust if it differs.

Providers are now a registry rather than a single service. /api/prices/status
lists each one with what it is configured for, what its numbers actually
mean, and how to enable it; refresh takes an optional provider name and
falls back to the first configured one. With none configured it answers 503
pointing at the CSV route.

Checked against the real library: a five-row guide in PriceCharting's own
column names priced four games and reported the fifth as not owned, with
each effective value following that copy's condition. Demo figures were
cleared afterwards.

135 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:46:10 -04:00
co-authored by Claude Opus 5
parent ca70bcef34
commit 8e136f42f8
8 changed files with 889 additions and 66 deletions
@@ -0,0 +1,136 @@
using System.Globalization;
using LudosData.Api.Services;
namespace LudosData.Api.Services.Pricing;
/// <summary>One row of an external price list.</summary>
public record PriceGuideRow(
string Title,
string? System,
decimal? Loose,
decimal? Cib,
decimal? New);
public record PriceGuideParseResult(
IReadOnlyList<PriceGuideRow> Rows,
IReadOnlyList<string> Problems);
/// <summary>
/// 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.
/// </summary>
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<string>();
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<PriceGuideRow>();
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);
}
/// <summary>
/// Tolerant of what a spreadsheet emits: currency symbols, thousands
/// separators, and integer pennies where a guide quotes them that way.
/// </summary>
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;
}
}