Make PriceCharting runs auditable and pin matches by product id

Two changes aimed at the first real run, since the integration cannot be
exercised here without a subscription.

Refresh now reports which product each game matched — name, console and the
source's id — next to the prices, and dry-run surfaces it before anything is
written. This is the failure that would otherwise go unnoticed: a lookup for
the DS "Chrono Trigger" resolving to the SNES original returns entirely
plausible numbers for the wrong game, and nothing in a bare price would say
so.

The matched id is then stored on the game, and later refreshes look it up
directly instead of repeating the title search. Cheaper, and stable — a
search that drifts to a different edition next month cannot silently
re-price something that was already matched correctly.

IPriceProvider takes an optional sourceId so this stays provider-agnostic.
eBay ignores it, having no stable per-product identifier in Browse.

139 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:52:59 -04:00
co-authored by Claude Opus 5
parent 8e136f42f8
commit d34e6b4ced
10 changed files with 619 additions and 9 deletions
+28
View File
@@ -263,6 +263,34 @@ 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.
**Using PriceCharting.** Subscribe, take the token from the Subscriptions page
("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access
comes with a paid subscription; the bulk CSV download is limited to their top
tier, so check which tier you need before subscribing — this integration does
per-game lookups and only needs the API.
Run a dry run first:
```bash
curl -X POST localhost:8080/api/prices/refresh \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"provider":"pricecharting","dryRun":true,"limit":10}'
```
The response reports **which product each game matched** — name, console and id
— alongside the prices. That matters more than it sounds: a lookup for the DS
"Chrono Trigger" that quietly resolves to the SNES original returns entirely
plausible numbers for the wrong game. Check the matches, then run without
`dryRun`.
Once a game is priced, the matched product id is stored and later refreshes look
it up directly, so they are cheaper and cannot drift to a different edition.
Their published API docs are not reachable without an account, so the response
parser follows the widely-used convention — integer pennies under hyphenated
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
### Database changes
```bash
@@ -30,7 +30,11 @@ public record PriceRefreshRequest
public record PriceRefreshItem(
int GameId, string Title,
decimal? Loose, decimal? Cib, decimal? New,
int Samples, int Discarded, string? Error);
int Samples, int Discarded, string? Error,
// What the source says it priced. A DS entry that quietly resolves to the
// SNES original returns plausible numbers for the wrong game, so a dry run
// has to show this before anything is written.
string? MatchedName = null, string? MatchedConsole = null, string? SourceId = null);
public record PriceRefreshResult(
bool DryRun, string Source, int Considered, int Priced, int Failed,
@@ -122,26 +126,32 @@ public class PricesController(
{
try
{
var estimate = await provider.EstimateAsync(game.Title, game.System, ct);
// Reuse a previous match where there is one, so refreshes stay
// pinned to the same product instead of re-running a search.
var estimate = await provider.EstimateAsync(
game.Title, game.System,
provider.Name == game.MarketValueSource ? game.PriceSourceId : null, ct);
if (!estimate.HasAnyPrice)
{
failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null,
0, estimate.Discarded, "No usable listings found"));
0, estimate.Discarded, "No price found for this title"));
continue;
}
if (!request.DryRun)
{
ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
game.PriceSourceId = estimate.SourceId ?? game.PriceSourceId;
}
priced++;
items.Add(new PriceRefreshItem(game.Id, game.Title,
estimate.Loose, estimate.Cib, estimate.New,
estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples,
estimate.Discarded, null));
estimate.Discarded, null,
estimate.MatchedName, estimate.MatchedConsole, estimate.SourceId));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -0,0 +1,410 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804205124_AddPriceSourceId")]
partial class AddPriceSourceId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("PriceSourceId")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddPriceSourceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PriceSourceId",
table: "Games",
type: "TEXT",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PriceSourceId",
table: "Games");
}
}
}
@@ -149,6 +149,10 @@ namespace LudosData.Api.Data.Migrations
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("PriceSourceId")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
+9
View File
@@ -94,6 +94,15 @@ public class Game
// re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete".
/// <summary>
/// The price source's identifier for this game, kept after the first match.
/// Later refreshes look it up directly instead of repeating a fuzzy search,
/// which makes them both cheaper and stable — a title search that drifts to
/// a different edition next month would silently re-price the wrong thing.
/// </summary>
[MaxLength(100)]
public string? PriceSourceId { get; set; }
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
@@ -35,7 +35,14 @@ public interface IPriceProvider
{
string Name { get; }
bool IsConfigured { get; }
Task<PriceEstimate> EstimateAsync(string title, string? system, CancellationToken ct = default);
/// <summary>
/// Prices a game. <paramref name="sourceId"/> is this provider's own
/// identifier from a previous match, when one is known — providers that can
/// use it should, since an exact lookup beats re-running a title search.
/// </summary>
Task<PriceEstimate> EstimateAsync(
string title, string? system, string? sourceId = null, CancellationToken ct = default);
}
/// <summary>
@@ -94,8 +101,12 @@ public class EbayPriceProvider(
};
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, CancellationToken ct = default)
string title, string? system, string? sourceId = null, CancellationToken ct = default)
{
// Browse has no stable per-product identifier to reuse; every lookup is
// a fresh search.
_ = sourceId;
if (!IsConfigured)
{
throw new InvalidOperationException(
@@ -70,7 +70,7 @@ public class PriceChartingProvider(
}
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, CancellationToken ct = default)
string title, string? system, string? sourceId = null, CancellationToken ct = default)
{
if (!IsConfigured)
{
@@ -79,9 +79,16 @@ public class PriceChartingProvider(
}
var client = httpClientFactory.CreateClient("pricecharting");
// An id from a previous match identifies the exact product; only fall
// back to searching by name when there is none.
var lookup = string.IsNullOrWhiteSpace(sourceId)
? $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"
: $"&id={Uri.EscapeDataString(sourceId)}";
var url = "https://www.pricecharting.com/api/product"
+ $"?t={Uri.EscapeDataString(_options.Token)}"
+ $"&q={Uri.EscapeDataString(BuildQuery(title, system))}";
+ lookup;
using var response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
@@ -130,7 +137,33 @@ public class PriceChartingProvider(
loose is null ? 0 : 1,
cib is null ? 0 : 1,
boxed is null ? 0 : 1,
0);
0)
{
// Carried through so a dry run can show which record was matched.
MatchedName = ReadString(root, "product-name", "productName"),
MatchedConsole = ReadString(root, "console-name", "consoleName"),
SourceId = ReadString(root, "id", "productId"),
};
}
private static string? ReadString(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out var element))
{
var value = element.ValueKind == JsonValueKind.String
? element.GetString()
: element.ToString();
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
}
return null;
}
/// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
@@ -21,6 +21,25 @@ public record PriceEstimate(
int NewSamples,
int Discarded)
{
/// <summary>
/// What the source thinks it priced, when it says so.
///
/// Prices are meaningless without knowing which record they came from: a
/// lookup for the DS "Chrono Trigger" that quietly resolves to the SNES
/// original returns plausible numbers for the wrong game. Surfacing the
/// matched title and console makes a dry run auditable instead of a leap of
/// faith.
/// </summary>
public string? MatchedName { get; init; }
public string? MatchedConsole { get; init; }
/// <summary>
/// The source's own identifier for the matched product, when it has one.
/// Storing it turns every later refresh into an exact lookup rather than a
/// repeat of the same fuzzy search.
/// </summary>
public string? SourceId { get; init; }
public bool HasAnyPrice => Loose is not null || Cib is not null || New is not null;
public static readonly PriceEstimate Empty = new(null, null, null, 0, 0, 0, 0);
@@ -367,3 +367,60 @@ public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture<LudosAp
decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
private record PagePayload(List<GamePayload> Items, int Total);
}
public class PriceChartingMatchTests
{
[Fact]
public void The_matched_product_and_console_are_reported()
{
using var document = JsonDocument.Parse("""
{
"status": "success",
"id": "6910",
"product-name": "Chrono Trigger",
"console-name": "Super Nintendo",
"loose-price": 12800, "cib-price": 65000, "new-price": 1200000
}
""");
var estimate = PriceChartingProvider.Parse(document);
// Without this a dry run cannot tell a DS entry that resolved to the
// SNES original from one that resolved correctly.
Assert.Equal("Chrono Trigger", estimate.MatchedName);
Assert.Equal("Super Nintendo", estimate.MatchedConsole);
Assert.Equal("6910", estimate.SourceId);
}
[Fact]
public void A_numeric_id_is_read_as_a_string()
{
using var document = JsonDocument.Parse("""
{ "status": "success", "id": 6910, "loose-price": 100 }
""");
Assert.Equal("6910", PriceChartingProvider.Parse(document).SourceId);
}
[Fact]
public void Missing_match_metadata_is_not_fatal()
{
using var document = JsonDocument.Parse("""{ "loose-price": 12800 }""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
Assert.Null(estimate.MatchedName);
Assert.Null(estimate.SourceId);
}
[Fact]
public void A_stored_id_is_preferred_over_a_title_search()
{
// Documents the intent of the lookup switch: with an id, the query is an
// exact product fetch, so a drifting title search cannot re-price a
// different edition on a later run.
Assert.Equal("super nintendo Chrono Trigger",
PriceChartingProvider.BuildQuery("Chrono Trigger", "SNES"));
}
}