diff --git a/SVSim.Database/Models/SerialCodeEntry.cs b/SVSim.Database/Models/SerialCodeEntry.cs
new file mode 100644
index 0000000..290e21a
--- /dev/null
+++ b/SVSim.Database/Models/SerialCodeEntry.cs
@@ -0,0 +1,27 @@
+using SVSim.Database.Common;
+
+namespace SVSim.Database.Models;
+
+///
+/// Top-level entity for a promotional serial code. Admin inserts these directly via SQL;
+/// there is no JSON seed or admin endpoint. Case-sensitive match on .
+///
+public class SerialCodeEntry : BaseEntity
+{
+ /// User-typed code. Case-sensitive; unique index enforces no duplicates.
+ public string Code { get; set; } = string.Empty;
+
+ /// Player-facing mail body, copied onto every ViewerPresent created at redemption.
+ public string Message { get; set; } = string.Empty;
+
+ /// When the code becomes valid. NULL = always valid from creation.
+ public DateTime? StartAt { get; set; }
+
+ /// When the code expires. NULL = never expires.
+ public DateTime? EndAt { get; set; }
+
+ /// Admin kill-switch. False = treat as if it doesn't exist.
+ public bool IsEnabled { get; set; }
+
+ public List Rewards { get; set; } = new List();
+}
diff --git a/SVSim.Database/Models/SerialCodeRewardEntry.cs b/SVSim.Database/Models/SerialCodeRewardEntry.cs
new file mode 100644
index 0000000..76bb44d
--- /dev/null
+++ b/SVSim.Database/Models/SerialCodeRewardEntry.cs
@@ -0,0 +1,24 @@
+using SVSim.Database.Common;
+
+namespace SVSim.Database.Models;
+
+///
+/// One reward slot belonging to a . On redemption each row
+/// becomes one in the player's gift inbox.
+///
+public class SerialCodeRewardEntry : BaseEntity
+{
+ public int SerialCodeId { get; set; }
+
+ /// 0-based ordering within the code's rewards.
+ public int Slot { get; set; }
+
+ /// UserGoodsType cast to int (matches the wire convention used elsewhere).
+ public int RewardType { get; set; }
+
+ /// Detail id for the goods. 0 for wallet currencies.
+ public long RewardDetailId { get; set; }
+
+ /// Positive integer count.
+ public int RewardCount { get; set; }
+}
diff --git a/SVSim.Database/Models/ViewerSerialCodeRedemption.cs b/SVSim.Database/Models/ViewerSerialCodeRedemption.cs
new file mode 100644
index 0000000..cc998f4
--- /dev/null
+++ b/SVSim.Database/Models/ViewerSerialCodeRedemption.cs
@@ -0,0 +1,13 @@
+namespace SVSim.Database.Models;
+
+///
+/// One row per (viewer, code) redemption. Composite PK on (ViewerId, SerialCodeId)
+/// enforces the single-use-per-viewer guarantee at the DB layer; the controller catches
+/// the unique-constraint violation as a race-condition backstop.
+///
+public class ViewerSerialCodeRedemption
+{
+ public long ViewerId { get; set; }
+ public int SerialCodeId { get; set; }
+ public DateTime RedeemedAt { get; set; }
+}
diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs
index 5aa2bd7..4e18e29 100644
--- a/SVSim.Database/SVSimDbContext.cs
+++ b/SVSim.Database/SVSimDbContext.cs
@@ -108,6 +108,10 @@ public class SVSimDbContext : DbContext
public DbSet ArenaTwoPickRewards { get; set; } = null!;
public DbSet ViewerArenaTwoPickRuns { get; set; } = null!;
+ public DbSet SerialCodes => Set();
+ public DbSet SerialCodeRewards => Set();
+ public DbSet ViewerSerialCodeRedemptions => Set();
+
#endregion
public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -418,6 +422,39 @@ public class SVSimDbContext : DbContext
b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id });
});
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => e.Id);
+ b.Property(e => e.Id).ValueGeneratedOnAdd();
+ b.Property(e => e.Code).HasMaxLength(64).IsRequired();
+ b.Property(e => e.Message).HasMaxLength(255);
+ b.HasIndex(e => e.Code).IsUnique();
+ b.HasMany(e => e.Rewards)
+ .WithOne()
+ .HasForeignKey(r => r.SerialCodeId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => e.Id);
+ b.Property(e => e.Id).ValueGeneratedOnAdd();
+ b.HasIndex(e => new { e.SerialCodeId, e.Slot });
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => new { e.ViewerId, e.SerialCodeId });
+ b.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.ViewerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.SerialCodeId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
base.OnModelCreating(modelBuilder);
}
diff --git a/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs b/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs
new file mode 100644
index 0000000..84b95e4
--- /dev/null
+++ b/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs
@@ -0,0 +1,92 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Persistence;
+
+public class SerialCodePersistenceTests
+{
+ [Test]
+ public async Task SerialCode_round_trips_with_rewards()
+ {
+ using var factory = new SVSimTestFactory();
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ ctx.SerialCodes.Add(new SerialCodeEntry
+ {
+ Code = "ABCD-1234",
+ Message = "Test code",
+ IsEnabled = true,
+ Rewards =
+ {
+ new SerialCodeRewardEntry { Slot = 0, RewardType = 1, RewardDetailId = 0, RewardCount = 100 },
+ new SerialCodeRewardEntry { Slot = 1, RewardType = 9, RewardDetailId = 0, RewardCount = 500 },
+ },
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx2 = verifyScope.ServiceProvider.GetRequiredService();
+ var code = await ctx2.SerialCodes
+ .Include(c => c.Rewards.OrderBy(r => r.Slot))
+ .AsNoTracking()
+ .FirstAsync(c => c.Code == "ABCD-1234");
+
+ Assert.That(code.Message, Is.EqualTo("Test code"));
+ Assert.That(code.IsEnabled, Is.True);
+ Assert.That(code.Rewards, Has.Count.EqualTo(2));
+ Assert.That(code.Rewards[0].RewardCount, Is.EqualTo(100));
+ Assert.That(code.Rewards[1].RewardCount, Is.EqualTo(500));
+ }
+
+ [Test]
+ public async Task Unique_constraint_on_Code_rejects_duplicates()
+ {
+ using var factory = new SVSimTestFactory();
+ using var scope = factory.Services.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+
+ ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "first", IsEnabled = true });
+ await ctx.SaveChangesAsync();
+
+ ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "second", IsEnabled = true });
+ Assert.That(async () => await ctx.SaveChangesAsync(), Throws.Exception);
+ }
+
+ [Test]
+ public async Task Composite_PK_on_redemption_rejects_double_redeem()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+
+ int codeId;
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ var code = new SerialCodeEntry { Code = "ONCE", Message = "single use", IsEnabled = true };
+ ctx.SerialCodes.Add(code);
+ await ctx.SaveChangesAsync();
+ codeId = code.Id;
+
+ ctx.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
+ {
+ ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow,
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ // Second redemption attempt in a fresh scope so the change tracker doesn't intercept
+ // before the DB constraint fires.
+ using var dupeScope = factory.Services.CreateScope();
+ var ctx2 = dupeScope.ServiceProvider.GetRequiredService();
+ ctx2.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
+ {
+ ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow,
+ });
+ Assert.That(async () => await ctx2.SaveChangesAsync(), Throws.Exception);
+ }
+}