diff --git a/.env.example b/.env.example index 56e18c9..ddaa166 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,23 @@ JWT_LIFETIME_MINUTES=720 JWT_ISSUER=LudosData JWT_AUDIENCE=LudosData CORS_ORIGIN=http://localhost:8080 + +# --- Market value (optional) ------------------------------------------------ +# Prices come from eBay's Browse API, which needs a free developer account. +# +# 1. Register at https://developer.ebay.com and create a developer account +# 2. Create an application keyset (Application Keys -> Production) +# 3. Copy the App ID (Client ID) and Cert ID (Client Secret) below +# +# Leave these blank and the pricing endpoints report 503 with an explanation; +# nothing else is affected. +# +# IMPORTANT: Browse returns ACTIVE LISTINGS, which are asking prices, not +# completed sales. eBay's sold-price data lives behind the Marketplace Insights +# API, which is a limited release not open to new developers. Expect these +# figures to read high — they are an upper bound, not a valuation. +EBAY_CLIENT_ID= +EBAY_CLIENT_SECRET= + +# Set true to use eBay's sandbox while checking credentials. +EBAY_USE_SANDBOX=false diff --git a/README.md b/README.md index dadcb4a..dea4c7e 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,46 @@ reinterpret every stored export. New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort 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. + +``` +GET /api/prices/status is a provider configured? +POST /api/prices/refresh {dryRun, limit} price some games +``` + +**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. + +Deriving a price from listings takes more than an average: + +- **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. + +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. + ### Database changes ```bash diff --git a/backend/src/LudosData.Api/Contracts/GameContracts.cs b/backend/src/LudosData.Api/Contracts/GameContracts.cs index 206cf09..30918ff 100644 --- a/backend/src/LudosData.Api/Contracts/GameContracts.cs +++ b/backend/src/LudosData.Api/Contracts/GameContracts.cs @@ -39,6 +39,9 @@ public record GameResponse( decimal? MarketValue, DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource, + decimal? ValueLoose, + decimal? ValueCib, + decimal? ValueNew, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -81,6 +84,10 @@ public record GameRequest [Range(0, 1_000_000)] public decimal? MarketValue { get; init; } [MaxLength(100)] public string? MarketValueSource { get; init; } + + [Range(0, 1_000_000)] public decimal? ValueLoose { get; init; } + [Range(0, 1_000_000)] public decimal? ValueCib { get; init; } + [Range(0, 1_000_000)] public decimal? ValueNew { get; init; } } /// Query string for the library list, bound from [FromQuery]. diff --git a/backend/src/LudosData.Api/Contracts/LibraryContracts.cs b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs index 19e054b..4c2422d 100644 --- a/backend/src/LudosData.Api/Contracts/LibraryContracts.cs +++ b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs @@ -42,6 +42,9 @@ public record ExportGame public decimal? MarketValue { get; init; } public DateTimeOffset? MarketValueUpdatedAt { get; init; } public string? MarketValueSource { get; init; } + public decimal? ValueLoose { get; init; } + public decimal? ValueCib { get; init; } + public decimal? ValueNew { get; init; } } /// Envelope written by the JSON exporter. diff --git a/backend/src/LudosData.Api/Controllers/GamesController.cs b/backend/src/LudosData.Api/Controllers/GamesController.cs index 3eb71ee..9d34fd7 100644 --- a/backend/src/LudosData.Api/Controllers/GamesController.cs +++ b/backend/src/LudosData.Api/Controllers/GamesController.cs @@ -194,19 +194,38 @@ public class GamesController( game.PurchasePrice = request.PurchasePrice; game.PurchaseDate = request.PurchaseDate; - // Only stamp the valuation when the figure actually changes, so an + var tiersChanged = request.ValueLoose != game.ValueLoose + || request.ValueCib != game.ValueCib + || request.ValueNew != game.ValueNew; + + game.ValueLoose = request.ValueLoose; + game.ValueCib = request.ValueCib; + game.ValueNew = request.ValueNew; + + // Only stamp the valuation when a figure actually changes, so an // unrelated edit does not make a stale price look freshly checked. - if (request.MarketValue != game.MarketValue) + if (request.MarketValue != game.MarketValue || tiersChanged) { game.MarketValue = request.MarketValue; - game.MarketValueUpdatedAt = request.MarketValue is null ? null : DateTimeOffset.UtcNow; - game.MarketValueSource = request.MarketValue is null + // Tiers win where they exist: they came from a source, and they + // follow the copy's condition. + game.RecalculateEffectiveValue(); + + game.MarketValueUpdatedAt = game.MarketValue is null ? null : DateTimeOffset.UtcNow; + game.MarketValueSource = game.MarketValue is null ? null : request.MarketValueSource?.Trim() ?? "manual"; } - else if (request.MarketValueSource is { } source && game.MarketValue is not null) + else { - game.MarketValueSource = source.Trim(); + // Condition may have moved without any price changing, which puts a + // different tier in play. + game.RecalculateEffectiveValue(); + + if (request.MarketValueSource is { } source && game.MarketValue is not null) + { + game.MarketValueSource = source.Trim(); + } } } @@ -217,5 +236,6 @@ public class GamesController( g.Rating, g.Notes, g.Condition, g.Region, g.PurchasePrice, g.PurchaseDate, g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource, + g.ValueLoose, g.ValueCib, g.ValueNew, g.CreatedAt, g.UpdatedAt); } diff --git a/backend/src/LudosData.Api/Controllers/LibraryController.cs b/backend/src/LudosData.Api/Controllers/LibraryController.cs index f280fa1..8d5d87e 100644 --- a/backend/src/LudosData.Api/Controllers/LibraryController.cs +++ b/backend/src/LudosData.Api/Controllers/LibraryController.cs @@ -33,7 +33,7 @@ public class LibraryController( "description", "art", "own", "dumped", "played", "finished", "rating", "notes", "condition", "region", "purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt", - "marketValueSource", + "marketValueSource", "valueLoose", "valueCib", "valueNew", ]; // Must match the converter registered on the controllers, so an export @@ -75,6 +75,9 @@ public class LibraryController( g.MarketValue?.ToString(CultureInfo.InvariantCulture), g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture), g.MarketValueSource, + g.ValueLoose?.ToString(CultureInfo.InvariantCulture), + g.ValueCib?.ToString(CultureInfo.InvariantCulture), + g.ValueNew?.ToString(CultureInfo.InvariantCulture), })); // A BOM keeps Excel from mangling non-ASCII titles such as Pokémon. @@ -294,6 +297,9 @@ public class LibraryController( MarketValue = ParseMoney(Field(row, "marketvalue")), MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")), MarketValueSource = Field(row, "marketvaluesource"), + ValueLoose = ParseMoney(Field(row, "valueloose")), + ValueCib = ParseMoney(Field(row, "valuecib")), + ValueNew = ParseMoney(Field(row, "valuenew")), }); } @@ -353,9 +359,14 @@ public class LibraryController( // The valuation's own timestamp is restored as recorded rather than // reset to now: an import is a restore, not a fresh price check. + target.ValueLoose = source.ValueLoose; + target.ValueCib = source.ValueCib; + target.ValueNew = source.ValueNew; + target.MarketValue = source.MarketValue; - target.MarketValueUpdatedAt = source.MarketValue is null ? null : source.MarketValueUpdatedAt; - target.MarketValueSource = source.MarketValue is null ? null : Blank(source.MarketValueSource); + target.RecalculateEffectiveValue(); + target.MarketValueUpdatedAt = target.MarketValue is null ? null : source.MarketValueUpdatedAt; + target.MarketValueSource = target.MarketValue is null ? null : Blank(source.MarketValueSource); } private static string? Blank(string? value) => @@ -384,5 +395,8 @@ public class LibraryController( MarketValue = g.MarketValue, MarketValueUpdatedAt = g.MarketValueUpdatedAt, MarketValueSource = g.MarketValueSource, + ValueLoose = g.ValueLoose, + ValueCib = g.ValueCib, + ValueNew = g.ValueNew, }; } diff --git a/backend/src/LudosData.Api/Controllers/PricesController.cs b/backend/src/LudosData.Api/Controllers/PricesController.cs new file mode 100644 index 0000000..7f90833 --- /dev/null +++ b/backend/src/LudosData.Api/Controllers/PricesController.cs @@ -0,0 +1,148 @@ +using LudosData.Api.Auth; +using LudosData.Api.Data; +using LudosData.Api.Services.Pricing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LudosData.Api.Controllers; + +public record PriceRefreshRequest +{ + /// Limit the run to specific games. Empty means the whole library. + public List? GameIds { get; init; } + + /// Re-price games that already have a figure. + public bool Overwrite { get; init; } + + /// Report what would change without writing anything. + public bool DryRun { get; init; } + + /// Ceiling on how many games one run will price. + public int Limit { get; init; } = 25; +} + +public record PriceRefreshItem( + 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, + IReadOnlyList Items); + +/// +/// Refreshes market values from a price provider. +/// +/// 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. +/// +[ApiController] +[Route("api/prices")] +[Authorize] +public class PricesController( + LudosDbContext db, + IPriceProvider provider, + ILogger logger) : ControllerBase +{ + [HttpGet("status")] + public IActionResult Status() => Ok(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", + }); + + [HttpPost("refresh")] + public async Task> Refresh( + PriceRefreshRequest request, CancellationToken ct) + { + if (!provider.IsConfigured) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails + { + Title = "No price provider is configured.", + Detail = "Set Ebay:ClientId and Ebay:ClientSecret, then restart the API.", + }); + } + + var ownerId = User.GetUserId(); + var query = db.Games.Where(g => g.OwnerId == ownerId); + + if (request.GameIds is { Count: > 0 }) + { + query = query.Where(g => request.GameIds.Contains(g.Id)); + } + else if (!request.Overwrite) + { + query = query.Where(g => g.MarketValue == null); + } + + var limit = Math.Clamp(request.Limit, 1, 200); + var games = await query.OrderBy(g => g.Title).Take(limit).ToListAsync(ct); + + var items = new List(); + int priced = 0, failed = 0; + + foreach (var game in games) + { + try + { + var estimate = await provider.EstimateAsync(game.Title, game.System, ct); + + if (!estimate.HasAnyPrice) + { + failed++; + items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, + 0, estimate.Discarded, "No usable listings found")); + continue; + } + + 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; + } + + priced++; + items.Add(new PriceRefreshItem(game.Id, game.Title, + estimate.Loose, estimate.Cib, estimate.New, + estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples, + estimate.Discarded, null)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // One bad lookup should not abandon the rest of the batch. + logger.LogWarning(ex, "Pricing failed for game {GameId}", game.Id); + failed++; + items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, 0, 0, ex.Message)); + } + } + + if (!request.DryRun && priced > 0) + { + await db.SaveChangesAsync(ct); + logger.LogInformation("User {OwnerId} priced {Count} games via {Source}", + ownerId, priced, provider.Name); + } + + return Ok(new PriceRefreshResult( + request.DryRun, provider.Name, games.Count, priced, failed, items)); + } +} diff --git a/backend/src/LudosData.Api/Data/LudosDbContext.cs b/backend/src/LudosData.Api/Data/LudosDbContext.cs index a550fe7..d1af4a3 100644 --- a/backend/src/LudosData.Api/Data/LudosDbContext.cs +++ b/backend/src/LudosData.Api/Data/LudosDbContext.cs @@ -32,6 +32,9 @@ public class LudosDbContext(DbContextOptions options) game.Property(g => g.PurchasePrice).HasConversion(moneyToCents); game.Property(g => g.MarketValue).HasConversion(moneyToCents); + game.Property(g => g.ValueLoose).HasConversion(moneyToCents); + game.Property(g => g.ValueCib).HasConversion(moneyToCents); + game.Property(g => g.ValueNew).HasConversion(moneyToCents); // Stored as an enum's underlying int; readable names live in the API. game.Property(g => g.Condition).HasConversion(); diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.Designer.cs b/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.Designer.cs new file mode 100644 index 0000000..88a16ad --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.Designer.cs @@ -0,0 +1,406 @@ +// +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("20260804191736_AddTieredPrices")] + partial class AddTieredPrices + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Condition") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Developer") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Dumped") + .HasColumnType("INTEGER"); + + b.Property("Finished") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MarketValue") + .HasColumnType("INTEGER"); + + b.Property("MarketValueSource") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MarketValueUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Own") + .HasColumnType("INTEGER"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Publisher") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PurchaseDate") + .HasColumnType("TEXT"); + + b.Property("PurchasePrice") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.Property("System") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueCib") + .HasColumnType("INTEGER"); + + b.Property("ValueLoose") + .HasColumnType("INTEGER"); + + b.Property("ValueNew") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.cs b/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.cs new file mode 100644 index 0000000..093bb05 --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804191736_AddTieredPrices.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LudosData.Api.Data.Migrations +{ + /// + public partial class AddTieredPrices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ValueCib", + table: "Games", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ValueLoose", + table: "Games", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ValueNew", + table: "Games", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ValueCib", + table: "Games"); + + migrationBuilder.DropColumn( + name: "ValueLoose", + table: "Games"); + + migrationBuilder.DropColumn( + name: "ValueNew", + table: "Games"); + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs index 8eb1bd0..d0c9e7d 100644 --- a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs +++ b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs @@ -177,6 +177,15 @@ namespace LudosData.Api.Data.Migrations b.Property("UpdatedAt") .HasColumnType("TEXT"); + b.Property("ValueCib") + .HasColumnType("INTEGER"); + + b.Property("ValueLoose") + .HasColumnType("INTEGER"); + + b.Property("ValueNew") + .HasColumnType("INTEGER"); + b.Property("Year") .HasMaxLength(50) .HasColumnType("TEXT"); diff --git a/backend/src/LudosData.Api/Domain/Game.cs b/backend/src/LudosData.Api/Domain/Game.cs index 0613c11..4a510e5 100644 --- a/backend/src/LudosData.Api/Domain/Game.cs +++ b/backend/src/LudosData.Api/Domain/Game.cs @@ -74,6 +74,13 @@ public class Game // captured and where it came from, because a figure with neither is not // something you can reason about — a total is only as good as its staleness. + /// + /// The figure used for totals, sorting and display: the tier matching this + /// copy's condition when tiers are known, otherwise whatever was entered by + /// hand. Denormalised deliberately — SQLite can sort and SUM a column, and + /// recomputing a CASE across three nullable columns in every query is worse + /// than keeping one value in step via . + /// public decimal? MarketValue { get; set; } public DateTimeOffset? MarketValueUpdatedAt { get; set; } @@ -82,6 +89,39 @@ public class Game [MaxLength(100)] public string? MarketValueSource { get; set; } + // Price sources quote per condition, and the spread between them is + // routinely a multiple. Keeping all three means changing a copy's condition + // re-prices it without another lookup, and the dashboard can answer both + // "what is this worth" and "what would it be worth complete". + + public decimal? ValueLoose { get; set; } + public decimal? ValueCib { get; set; } + public decimal? ValueNew { get; set; } + + /// The tier that applies to a given condition, if it is known. + public decimal? TierFor(GameCondition condition) => condition switch + { + GameCondition.Sealed => ValueNew, + GameCondition.Cib => ValueCib, + GameCondition.Loose => ValueLoose, + // Digital has no physical tier, and an unspecified condition is most + // often a loose cart or disc, which is also the conservative estimate. + _ => ValueLoose, + }; + + /// + /// Brings back in step with the tiers. A hand-typed + /// figure survives: it is only replaced once a source has supplied tiers. + /// + public void RecalculateEffectiveValue() + { + var tier = TierFor(Condition); + if (tier is not null) + { + MarketValue = tier; + } + } + /// /// Owning user. Every query is filtered on this server-side, from the JWT subject — /// it is never accepted from the client. diff --git a/backend/src/LudosData.Api/LudosData.Api.csproj b/backend/src/LudosData.Api/LudosData.Api.csproj index 3d95551..20e331c 100644 --- a/backend/src/LudosData.Api/LudosData.Api.csproj +++ b/backend/src/LudosData.Api/LudosData.Api.csproj @@ -36,4 +36,11 @@ + + + + + diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs index 075841e..86d486d 100644 --- a/backend/src/LudosData.Api/Program.cs +++ b/backend/src/LudosData.Api/Program.cs @@ -4,6 +4,7 @@ using LudosData.Api.Auth; using LudosData.Api.Data; using LudosData.Api.Domain; using LudosData.Api.Services; +using LudosData.Api.Services.Pricing; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -113,6 +114,13 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); 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)); +builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddSingleton(); + builder.Services .AddControllers() .AddJsonOptions(options => diff --git a/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs new file mode 100644 index 0000000..d4db639 --- /dev/null +++ b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs @@ -0,0 +1,243 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using LudosData.Api.Domain; +using Microsoft.Extensions.Options; + +namespace LudosData.Api.Services.Pricing; + +public class EbayOptions +{ + public const string SectionName = "Ebay"; + + /// App ID (Client ID) from the eBay developer portal. + public string ClientId { get; set; } = string.Empty; + + /// Cert ID (Client Secret). + public string ClientSecret { get; set; } = string.Empty; + + /// Marketplace to price against. Changing this changes the currency. + public string Marketplace { get; set; } = "EBAY_US"; + + /// Video Games category, to keep guides and accessories out of the sample. + public string CategoryId { get; set; } = "139973"; + + /// Listings to consider per game. More is slower and rarely more accurate. + public int MaxListings { get; set; } = 50; + + /// Sandbox endpoints, for trying credentials without touching production. + public bool UseSandbox { get; set; } + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret); +} + +public interface IPriceProvider +{ + string Name { get; } + bool IsConfigured { get; } + Task EstimateAsync(string title, string? system, CancellationToken ct = default); +} + +/// +/// Estimates prices from eBay's Browse API. +/// +/// An important caveat, carried through to the UI: Browse returns active +/// listings, which are asking prices. eBay's sold-item data lives behind the +/// Marketplace Insights API, which is a limited release not open to new +/// developers. Asking prices skew high — sellers list optimistically and +/// unsold listings persist — so these figures are an upper bound on what a copy +/// would actually fetch, and are labelled as such rather than presented as a +/// valuation. +/// +public class EbayPriceProvider( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) : IPriceProvider +{ + private readonly EbayOptions _options = options.Value; + + private string? _token; + private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue; + private readonly SemaphoreSlim _tokenLock = new(1, 1); + + public string Name => "ebay-asking"; + + public bool IsConfigured => _options.IsConfigured; + + private string ApiHost => _options.UseSandbox + ? "https://api.sandbox.ebay.com" + : "https://api.ebay.com"; + + /// + /// Search terms that keep the sample on the right platform. Without the + /// console name, "Chrono Trigger" returns SNES, DS and PS1 copies together + /// and the median lands between three different markets. + /// + internal static string BuildQuery(string title, string? system) => + string.IsNullOrWhiteSpace(system) ? title : $"{title} {SystemSearchTerm(system)}"; + + internal static string SystemSearchTerm(string system) => system.ToUpperInvariant() switch + { + "NES" => "Nintendo NES", + "SNES" => "Super Nintendo SNES", + "N64" => "Nintendo 64", + "GC" => "GameCube", + "WII" => "Nintendo Wii", + "GB" => "Game Boy", + "GBA" => "Game Boy Advance", + "DS" => "Nintendo DS", + "PS1" => "PlayStation 1 PS1", + "PS2" => "PlayStation 2 PS2", + "PSP" => "PSP", + "360" => "Xbox 360", + _ => system, + }; + + public async Task EstimateAsync( + string title, string? system, CancellationToken ct = default) + { + if (!IsConfigured) + { + throw new InvalidOperationException( + "eBay credentials are not configured. Set Ebay:ClientId and Ebay:ClientSecret."); + } + + var token = await GetTokenAsync(ct); + var client = httpClientFactory.CreateClient("ebay"); + + var query = Uri.EscapeDataString(BuildQuery(title, system)); + var url = $"{ApiHost}/buy/browse/v1/item_summary/search" + + $"?q={query}&limit={_options.MaxListings}" + + $"&filter=buyingOptions:{{FIXED_PRICE}}" + + (string.IsNullOrWhiteSpace(_options.CategoryId) + ? string.Empty + : $"&category_ids={_options.CategoryId}"); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", _options.Marketplace); + + using var response = await client.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("eBay search for {Title} returned {Status}", title, response.StatusCode); + return PriceEstimate.Empty; + } + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return Parse(await JsonDocument.ParseAsync(stream, cancellationToken: ct)); + } + + /// Turns a Browse search response into an estimate. Internal so it can be tested on fixtures. + internal static PriceEstimate Parse(JsonDocument document) + { + if (!document.RootElement.TryGetProperty("itemSummaries", out var summaries) + || summaries.ValueKind != JsonValueKind.Array) + { + return PriceEstimate.Empty; + } + + var listings = new List(); + var discarded = 0; + + foreach (var item in summaries.EnumerateArray()) + { + var title = item.TryGetProperty("title", out var t) ? t.GetString() : null; + var sellerCondition = item.TryGetProperty("condition", out var c) ? c.GetString() : null; + + if (!TryReadPrice(item, out var price)) + { + discarded++; + continue; + } + + var tier = ListingCondition.Classify(title, sellerCondition); + if (tier is null) + { + discarded++; + continue; + } + + listings.Add(new PricedListing(price, tier.Value)); + } + + return PriceMath.Summarise(listings, discarded); + } + + private static bool TryReadPrice(JsonElement item, out decimal price) + { + price = 0m; + + if (!item.TryGetProperty("price", out var priceElement) + || !priceElement.TryGetProperty("value", out var valueElement)) + { + return false; + } + + // Browse reports the amount as a string. + var raw = valueElement.ValueKind == JsonValueKind.String + ? valueElement.GetString() + : valueElement.ToString(); + + if (!decimal.TryParse(raw, System.Globalization.NumberStyles.Number, + System.Globalization.CultureInfo.InvariantCulture, out price)) + { + return false; + } + + // A listing at or near zero is a placeholder, not a price. + return price > 0.5m; + } + + /// + /// Client-credentials token, cached until shortly before it expires. eBay + /// issues these for two hours and rate-limits the token endpoint, so + /// requesting one per game would fail long before the search quota did. + /// + private async Task GetTokenAsync(CancellationToken ct) + { + if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt) + { + return _token; + } + + await _tokenLock.WaitAsync(ct); + try + { + if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt) + { + return _token; + } + + var client = httpClientFactory.CreateClient("ebay"); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiHost}/identity/v1/oauth2/token"); + + var basic = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes($"{_options.ClientId}:{_options.ClientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["scope"] = "https://api.ebay.com/oauth/api_scope", + }); + + using var response = await client.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + _token = document.RootElement.GetProperty("access_token").GetString(); + + var seconds = document.RootElement.TryGetProperty("expires_in", out var e) + ? e.GetInt32() : 7200; + // Retire it a minute early rather than discover expiry mid-batch. + _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds - 60); + + return _token!; + } + finally + { + _tokenLock.Release(); + } + } +} diff --git a/backend/src/LudosData.Api/Services/Pricing/ListingCondition.cs b/backend/src/LudosData.Api/Services/Pricing/ListingCondition.cs new file mode 100644 index 0000000..f3f7168 --- /dev/null +++ b/backend/src/LudosData.Api/Services/Pricing/ListingCondition.cs @@ -0,0 +1,106 @@ +using System.Text.RegularExpressions; +using LudosData.Api.Domain; + +namespace LudosData.Api.Services.Pricing; + +/// +/// Sorts a marketplace listing into a condition tier from its title and the +/// seller's own condition flag. +/// +/// This is the weakest link in deriving prices from active listings, and it is +/// isolated here so it can be tested on its own. Sellers do not use a controlled +/// vocabulary: "CIB", "complete in box", "w/ manual" and "boxed" all mean the +/// same tier, while "box only" and "manual only" mean there is no game at all +/// and the listing must be discarded rather than counted as cheap. +/// +public static partial class ListingCondition +{ + /// + /// Listings that are not a copy of the game, at any condition. + /// + /// The qualifier is required, not optional. Matching a bare "box" would + /// discard "complete in box" and "with box and manual" — that is, most of + /// the CIB tier — while leaving the cheap box-only listings that the filter + /// exists to remove. + /// + [GeneratedRegex( + @"\b(?:" + + @"(?:box|case|manual|instructions?|insert|artwork|art\s*work|cover|label|" + + @"poster|sticker|protector|display|shell)\s+only" + + @"|only\s+(?:the\s+)?(?:box|case|manual|cover)" + + @"|empty\s+(?:box|case)" + + @"|no\s+(?:game|cart|cartridge|disc)" + + @"|(?:custom|replacement|repro|reproduction)\s+(?:art|label|case|box|cover|manual)" + + @"|(?:art|label|case|box|cover|manual)\s+(?:replacement|repro)" + + @")\b", + RegexOptions.IgnoreCase)] + private static partial Regex AccessoryPattern(); + + /// Explicitly not a genuine retail copy. + [GeneratedRegex(@"\b(repro|reproduction|bootleg|fake|counterfeit|homebrew|aftermarket)\b", + RegexOptions.IgnoreCase)] + private static partial Regex CounterfeitPattern(); + + /// A bundle prices several games at once and would skew a median. + [GeneratedRegex(@"\b(lot|bundle|collection\s+of|\d+\s*games?|joblot|job\s+lot)\b", + RegexOptions.IgnoreCase)] + private static partial Regex LotPattern(); + + [GeneratedRegex(@"\b(sealed|factory\s*sealed|brand\s*new|bnib|nib|vga|wata|graded)\b", + RegexOptions.IgnoreCase)] + private static partial Regex SealedPattern(); + + [GeneratedRegex( + @"\b(cib|complete\s*in\s*box|complete|boxed|with\s*(box|manual|case)|" + + @"w/\s*(box|manual|case)|box\s*and\s*manual)\b", + RegexOptions.IgnoreCase)] + private static partial Regex CompletePattern(); + + [GeneratedRegex(@"\b(loose|cart\s*only|cartridge\s*only|disc\s*only|game\s*only|unboxed)\b", + RegexOptions.IgnoreCase)] + private static partial Regex LoosePattern(); + + /// + /// The tier a listing belongs to, or null when it should not be counted — + /// an accessory, a reproduction, or a multi-game lot. + /// + public static GameCondition? Classify(string? title, string? sellerCondition) + { + var text = title ?? string.Empty; + + // Discard first. A "box only" listing at $8 would otherwise drag a + // loose-cart median down to nonsense. + if (AccessoryPattern().IsMatch(text) + || CounterfeitPattern().IsMatch(text) + || LotPattern().IsMatch(text)) + { + return null; + } + + if (SealedPattern().IsMatch(text)) + { + return GameCondition.Sealed; + } + + if (CompletePattern().IsMatch(text)) + { + return GameCondition.Cib; + } + + if (LoosePattern().IsMatch(text)) + { + return GameCondition.Loose; + } + + // Nothing in the title said. Fall back to the seller's own flag, which + // only distinguishes new from used. + if (string.Equals(sellerCondition, "New", StringComparison.OrdinalIgnoreCase)) + { + return GameCondition.Sealed; + } + + // An unqualified used listing is most often a loose cart or disc, and + // that is also the conservative reading. + return GameCondition.Loose; + } +} diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs new file mode 100644 index 0000000..4665548 --- /dev/null +++ b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs @@ -0,0 +1,100 @@ +using LudosData.Api.Domain; + +namespace LudosData.Api.Services.Pricing; + +/// One priced listing, after classification. +public record PricedListing(decimal Price, GameCondition Tier); + +/// +/// A per-condition estimate plus how much evidence sits behind it. +/// +/// The sample counts are part of the result, not diagnostics: a tier derived +/// from two listings deserves less confidence than one derived from thirty, and +/// the caller needs to be able to say so. +/// +public record PriceEstimate( + decimal? Loose, + decimal? Cib, + decimal? New, + int LooseSamples, + int CibSamples, + int NewSamples, + int Discarded) +{ + 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); +} + +public static class PriceMath +{ + /// + /// Median, not mean. Marketplace listings carry outliers in both directions — + /// an optimist asking ten times the going rate, or a mispriced bargain — and + /// a mean chases them while a median does not. + /// + public static decimal? Median(IReadOnlyList values) + { + if (values.Count == 0) + { + return null; + } + + var sorted = values.OrderBy(v => v).ToArray(); + var middle = sorted.Length / 2; + + return sorted.Length % 2 == 1 + ? sorted[middle] + : Math.Round((sorted[middle - 1] + sorted[middle]) / 2m, 2); + } + + /// + /// Drops prices far outside the bulk of the sample before taking a median. + /// + /// Uses the interquartile range rather than standard deviations: listing + /// prices are not normally distributed, and a single graded copy at 50x + /// would widen a standard deviation enough to protect itself. + /// + public static List RemoveOutliers(IReadOnlyList values) + { + if (values.Count < 4) + { + // Too few points for quartiles to mean anything. + return [.. values]; + } + + var sorted = values.OrderBy(v => v).ToArray(); + var q1 = sorted[sorted.Length / 4]; + var q3 = sorted[sorted.Length * 3 / 4]; + var iqr = q3 - q1; + + if (iqr <= 0) + { + return [.. values]; + } + + var low = q1 - 1.5m * iqr; + var high = q3 + 1.5m * iqr; + + return sorted.Where(v => v >= low && v <= high).ToList(); + } + + /// Aggregates classified listings into a per-tier estimate. + public static PriceEstimate Summarise(IReadOnlyList listings, int discarded) + { + decimal? TierPrice(GameCondition tier, out int samples) + { + var prices = listings.Where(l => l.Tier == tier).Select(l => l.Price).ToList(); + var kept = RemoveOutliers(prices); + samples = kept.Count; + return Median(kept); + } + + var loose = TierPrice(GameCondition.Loose, out var looseSamples); + var cib = TierPrice(GameCondition.Cib, out var cibSamples); + var sealedPrice = TierPrice(GameCondition.Sealed, out var newSamples); + + return new PriceEstimate( + loose, cib, sealedPrice, looseSamples, cibSamples, newSamples, discarded); + } +} diff --git a/backend/tests/LudosData.Api.Tests/PricingTests.cs b/backend/tests/LudosData.Api.Tests/PricingTests.cs new file mode 100644 index 0000000..fb51572 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/PricingTests.cs @@ -0,0 +1,238 @@ +using System.Text.Json; +using LudosData.Api.Domain; +using LudosData.Api.Services.Pricing; + +namespace LudosData.Api.Tests; + +/// +/// The pricing logic, tested without touching eBay. +/// +/// Everything that decides what a number means — which listings count, which +/// tier they land in, and how they aggregate — is pure and lives here. Only the +/// HTTP call itself needs credentials, and it is the least interesting part. +/// +public class ListingClassificationTests +{ + [Theory] + [InlineData("Chrono Trigger SNES Cartridge Only", GameCondition.Loose)] + [InlineData("Super Metroid - loose cart, tested", GameCondition.Loose)] + [InlineData("Banjo-Kazooie N64 game only", GameCondition.Loose)] + [InlineData("Earthbound SNES CIB", GameCondition.Cib)] + [InlineData("Ocarina of Time complete in box", GameCondition.Cib)] + [InlineData("Mario Kart 64 with box and manual", GameCondition.Cib)] + [InlineData("Boxed Pokemon Yellow Game Boy", GameCondition.Cib)] + [InlineData("Metroid Prime FACTORY SEALED", GameCondition.Sealed)] + [InlineData("Halo 3 Xbox 360 Brand New Sealed", GameCondition.Sealed)] + [InlineData("Final Fantasy VII WATA 9.4 graded", GameCondition.Sealed)] + public void Titles_are_sorted_into_the_right_tier(string title, GameCondition expected) + { + Assert.Equal(expected, ListingCondition.Classify(title, "Used")); + } + + [Theory] + // These are the dangerous ones: cheap, plentiful, and not the game. + [InlineData("Chrono Trigger SNES BOX ONLY no game")] + [InlineData("Super Mario World manual only")] + [InlineData("Zelda Ocarina of Time REPRODUCTION cartridge")] + [InlineData("N64 game case replacement")] + [InlineData("Custom art label for Earthbound")] + [InlineData("Lot of 12 SNES games")] + [InlineData("Nintendo 64 bundle 5 games")] + public void Accessories_reproductions_and_lots_are_discarded(string title) + { + // Counting a $6 "box only" listing as a copy of the game would drag a + // loose median to nonsense. + Assert.Null(ListingCondition.Classify(title, "Used")); + } + + [Fact] + public void An_unqualified_listing_falls_back_to_the_sellers_flag() + { + Assert.Equal(GameCondition.Sealed, ListingCondition.Classify("Chrono Trigger", "New")); + // Unqualified and used reads as loose, the conservative assumption. + Assert.Equal(GameCondition.Loose, ListingCondition.Classify("Chrono Trigger", "Used")); + } +} + +public class PriceMathTests +{ + [Fact] + public void Median_of_an_odd_sample_is_the_middle_value() + { + // Sorted first: [10, 20, 30]. + Assert.Equal(20m, PriceMath.Median([10m, 30m, 20m])); + } + + [Fact] + public void Median_of_an_even_sample_averages_the_middle_pair() + { + Assert.Equal(25m, PriceMath.Median([10m, 20m, 30m, 40m])); + } + + [Fact] + public void Median_of_nothing_is_null_rather_than_zero() + { + // A game with no listings is unpriced, which is not the same as free. + Assert.Null(PriceMath.Median([])); + } + + [Fact] + public void A_wildly_optimistic_listing_does_not_move_the_estimate() + { + var withOutlier = new List { 40m, 42m, 45m, 44m, 43m, 41m, 5000m }; + + var kept = PriceMath.RemoveOutliers(withOutlier); + + Assert.DoesNotContain(5000m, kept); + // A mean would have been dragged past 750; the median holds. + Assert.InRange(PriceMath.Median(kept)!.Value, 40m, 45m); + } + + [Fact] + public void Small_samples_are_left_alone() + { + // With three points, quartiles are meaningless and trimming would throw + // away most of the evidence. + var values = new List { 10m, 20m, 900m }; + Assert.Equal(3, PriceMath.RemoveOutliers(values).Count); + } + + [Fact] + public void Summarise_reports_a_price_and_a_sample_count_per_tier() + { + var listings = new List + { + new(20m, GameCondition.Loose), + new(24m, GameCondition.Loose), + new(22m, GameCondition.Loose), + new(80m, GameCondition.Cib), + new(90m, GameCondition.Cib), + new(400m, GameCondition.Sealed), + }; + + var estimate = PriceMath.Summarise(listings, discarded: 4); + + Assert.Equal(22m, estimate.Loose); + Assert.Equal(85m, estimate.Cib); + Assert.Equal(400m, estimate.New); + Assert.Equal(3, estimate.LooseSamples); + Assert.Equal(2, estimate.CibSamples); + Assert.Equal(1, estimate.NewSamples); + Assert.Equal(4, estimate.Discarded); + Assert.True(estimate.HasAnyPrice); + } + + [Fact] + public void A_tier_with_no_listings_stays_null() + { + var estimate = PriceMath.Summarise([new PricedListing(20m, GameCondition.Loose)], 0); + + Assert.Equal(20m, estimate.Loose); + Assert.Null(estimate.Cib); + Assert.Null(estimate.New); + } +} + +public class EbayResponseParsingTests +{ + /// Shaped like a real Browse item_summary/search response. + private const string SampleResponse = """ + { + "total": 8, + "itemSummaries": [ + { "title": "Chrono Trigger SNES Cartridge Only Authentic", + "condition": "Used", "price": { "value": "120.00", "currency": "USD" } }, + { "title": "Chrono Trigger Super Nintendo loose cart tested", + "condition": "Used", "price": { "value": "135.50", "currency": "USD" } }, + { "title": "Chrono Trigger SNES game only", + "condition": "Used", "price": { "value": "128.00", "currency": "USD" } }, + { "title": "Chrono Trigger SNES CIB complete in box", + "condition": "Used", "price": { "value": "650.00", "currency": "USD" } }, + { "title": "Chrono Trigger Super Nintendo with box and manual", + "condition": "Used", "price": { "value": "700.00", "currency": "USD" } }, + { "title": "Chrono Trigger SNES FACTORY SEALED WATA", + "condition": "New", "price": { "value": "12000.00", "currency": "USD" } }, + { "title": "Chrono Trigger SNES BOX ONLY no game", + "condition": "Used", "price": { "value": "45.00", "currency": "USD" } }, + { "title": "Lot of 6 SNES RPG games including Chrono Trigger", + "condition": "Used", "price": { "value": "300.00", "currency": "USD" } } + ] + } + """; + + [Fact] + public void A_search_response_is_split_into_tiers() + { + using var document = JsonDocument.Parse(SampleResponse); + + var estimate = EbayPriceProvider.Parse(document); + + Assert.Equal(128.00m, estimate.Loose); // median of 120, 128, 135.50 + Assert.Equal(675.00m, estimate.Cib); // mean of the middle pair + Assert.Equal(12000.00m, estimate.New); + + // The box-only listing and the multi-game lot are both thrown out. Left + // in, the $45 box would have halved the loose estimate. + Assert.Equal(2, estimate.Discarded); + Assert.Equal(3, estimate.LooseSamples); + } + + [Fact] + public void A_response_with_no_results_yields_no_prices() + { + using var document = JsonDocument.Parse("""{ "total": 0, "itemSummaries": [] }"""); + + var estimate = EbayPriceProvider.Parse(document); + + Assert.False(estimate.HasAnyPrice); + } + + [Fact] + public void A_response_missing_the_results_array_does_not_throw() + { + using var document = JsonDocument.Parse("""{ "total": 0, "warnings": [] }"""); + + Assert.False(EbayPriceProvider.Parse(document).HasAnyPrice); + } + + [Fact] + public void Listings_without_a_usable_price_are_discarded() + { + using var document = JsonDocument.Parse(""" + { + "itemSummaries": [ + { "title": "Chrono Trigger SNES loose", "condition": "Used" }, + { "title": "Chrono Trigger SNES loose", "condition": "Used", + "price": { "value": "0.00", "currency": "USD" } }, + { "title": "Chrono Trigger SNES loose", "condition": "Used", + "price": { "value": "130.00", "currency": "USD" } } + ] + } + """); + + var estimate = EbayPriceProvider.Parse(document); + + Assert.Equal(130.00m, estimate.Loose); + Assert.Equal(1, estimate.LooseSamples); + Assert.Equal(2, estimate.Discarded); + } + + [Theory] + [InlineData("SNES", "Super Nintendo SNES")] + [InlineData("N64", "Nintendo 64")] + [InlineData("360", "Xbox 360")] + [InlineData("PS1", "PlayStation 1 PS1")] + public void The_console_name_is_added_to_the_search(string system, string expected) + { + // Without it, "Chrono Trigger" returns SNES, PS1 and DS copies together + // and the median lands between three different markets. + Assert.Equal($"Chrono Trigger {expected}", + EbayPriceProvider.BuildQuery("Chrono Trigger", system)); + } + + [Fact] + public void A_game_with_no_system_searches_on_title_alone() + { + Assert.Equal("Chrono Trigger", EbayPriceProvider.BuildQuery("Chrono Trigger", null)); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index bd42116..9740cff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,12 @@ services: Seed__Email: ${SEED_EMAIL:-} Seed__Password: ${SEED_PASSWORD:-} + # Optional market-value lookups. Blank means the pricing endpoints report + # 503 and everything else carries on. + Ebay__ClientId: ${EBAY_CLIENT_ID:-} + Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-} + Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false} + # Only consulted when the SPA is served from somewhere other than nginx. Cors__AllowedOrigins__0: ${CORS_ORIGIN:-http://localhost:8080} Cors__AllowedOrigins__1: http://localhost:4200