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 lookup. Every value carries the source that wrote it and the moment it was
captured. 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 ### Database changes
```bash ```bash
@@ -30,7 +30,11 @@ public record PriceRefreshRequest
public record PriceRefreshItem( public record PriceRefreshItem(
int GameId, string Title, int GameId, string Title,
decimal? Loose, decimal? Cib, decimal? New, 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( public record PriceRefreshResult(
bool DryRun, string Source, int Considered, int Priced, int Failed, bool DryRun, string Source, int Considered, int Priced, int Failed,
@@ -122,26 +126,32 @@ public class PricesController(
{ {
try 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) if (!estimate.HasAnyPrice)
{ {
failed++; failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, 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; continue;
} }
if (!request.DryRun) if (!request.DryRun)
{ {
ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name); ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
game.PriceSourceId = estimate.SourceId ?? game.PriceSourceId;
} }
priced++; priced++;
items.Add(new PriceRefreshItem(game.Id, game.Title, items.Add(new PriceRefreshItem(game.Id, game.Title,
estimate.Loose, estimate.Cib, estimate.New, estimate.Loose, estimate.Cib, estimate.New,
estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples, 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) 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") b.Property<bool>("Played")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("PriceSourceId")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Publisher") b.Property<string>("Publisher")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+9
View File
@@ -94,6 +94,15 @@ public class Game
// re-prices it without another lookup, and the dashboard can answer both // re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete". // "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? ValueLoose { get; set; }
public decimal? ValueCib { get; set; } public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; } public decimal? ValueNew { get; set; }
@@ -35,7 +35,14 @@ public interface IPriceProvider
{ {
string Name { get; } string Name { get; }
bool IsConfigured { 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> /// <summary>
@@ -94,8 +101,12 @@ public class EbayPriceProvider(
}; };
public async Task<PriceEstimate> EstimateAsync( 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) if (!IsConfigured)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -70,7 +70,7 @@ public class PriceChartingProvider(
} }
public async Task<PriceEstimate> EstimateAsync( public async Task<PriceEstimate> EstimateAsync(
string title, string? system, CancellationToken ct = default) string title, string? system, string? sourceId = null, CancellationToken ct = default)
{ {
if (!IsConfigured) if (!IsConfigured)
{ {
@@ -79,9 +79,16 @@ public class PriceChartingProvider(
} }
var client = httpClientFactory.CreateClient("pricecharting"); 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" var url = "https://www.pricecharting.com/api/product"
+ $"?t={Uri.EscapeDataString(_options.Token)}" + $"?t={Uri.EscapeDataString(_options.Token)}"
+ $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"; + lookup;
using var response = await client.GetAsync(url, ct); using var response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -130,7 +137,33 @@ public class PriceChartingProvider(
loose is null ? 0 : 1, loose is null ? 0 : 1,
cib is null ? 0 : 1, cib is null ? 0 : 1,
boxed 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> /// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
@@ -21,6 +21,25 @@ public record PriceEstimate(
int NewSamples, int NewSamples,
int Discarded) 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 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 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); decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
private record PagePayload(List<GamePayload> Items, int Total); 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"));
}
}