diff --git a/SVSim.Database/Services/Inventory/GrantSource.cs b/SVSim.Database/Services/Inventory/GrantSource.cs
new file mode 100644
index 0000000..f3a383c
--- /dev/null
+++ b/SVSim.Database/Services/Inventory/GrantSource.cs
@@ -0,0 +1,61 @@
+namespace SVSim.Database.Services.Inventory;
+
+///
+/// Logical source of a grant routed through .
+/// Stored verbatim in viewer_acquire_history.AcquireType and surfaced on the
+/// /item_acquire_history/info wire as acquire_type.
+///
+///
+/// Values are persisted to the database — renumbering after ship requires a migration.
+/// Values 1 and 2 mirror the prod capture in
+/// data_dumps/captures/traffic_prod_misc_clicking.ndjson; the rest are our own.
+///
+public enum GrantSource
+{
+ Unknown = 0,
+ DailyBonus = 1,
+ PackOpen = 2,
+ PuzzleReward = 3,
+ StoryFinish = 4,
+ BattlePassClaim = 5,
+ MissionReward = 6,
+ ArenaTwoPickFinish = 7,
+ ItemPurchase = 8,
+ BuildDeckBuy = 9,
+ SleeveBuy = 10,
+ LeaderSkinBuy = 11,
+ GachaPointExchange = 12,
+ AchievementReward = 13,
+ SerialCodeRedeem = 14,
+ CardCosmeticCascade = 15,
+ AdminGrant = 99,
+}
+
+///
+/// Pre-localized text written into the message field of an item-acquire-history row.
+/// The client renders this string verbatim, so all entries are user-facing English.
+///
+public static class GrantSourceMessages
+{
+ public static string For(GrantSource source) => source switch
+ {
+ GrantSource.Unknown => "Unknown",
+ GrantSource.DailyBonus => "Daily Bonus",
+ GrantSource.PackOpen => "From buying card packs",
+ GrantSource.PuzzleReward => "From puzzle reward",
+ GrantSource.StoryFinish => "From story reward",
+ GrantSource.BattlePassClaim => "From battle pass reward",
+ GrantSource.MissionReward => "From mission reward",
+ GrantSource.ArenaTwoPickFinish => "From 2Pick reward",
+ GrantSource.ItemPurchase => "From shop purchase",
+ GrantSource.BuildDeckBuy => "From starter set purchase",
+ GrantSource.SleeveBuy => "From sleeve purchase",
+ GrantSource.LeaderSkinBuy => "From leader skin purchase",
+ GrantSource.GachaPointExchange => "From point exchange",
+ GrantSource.AchievementReward => "From achievement reward",
+ GrantSource.SerialCodeRedeem => "From serial code",
+ GrantSource.CardCosmeticCascade => "Card cosmetic",
+ GrantSource.AdminGrant => "From admin grant",
+ _ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unhandled GrantSource"),
+ };
+}
diff --git a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs
new file mode 100644
index 0000000..65d9dce
--- /dev/null
+++ b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs
@@ -0,0 +1,26 @@
+using SVSim.Database.Services.Inventory;
+
+namespace SVSim.UnitTests.Services.Inventory;
+
+public class InventoryHistoryTests
+{
+ [Test]
+ public void GrantSourceMessages_returns_known_messages_for_seeded_sources()
+ {
+ Assert.That(GrantSourceMessages.For(GrantSource.DailyBonus), Is.EqualTo("Daily Bonus"));
+ Assert.That(GrantSourceMessages.For(GrantSource.PackOpen), Is.EqualTo("From buying card packs"));
+ Assert.That(GrantSourceMessages.For(GrantSource.CardCosmeticCascade), Is.EqualTo("Card cosmetic"));
+ Assert.That(GrantSourceMessages.For(GrantSource.Unknown), Is.EqualTo("Unknown"));
+ }
+
+ [Test]
+ public void GrantSourceMessages_covers_every_enum_value()
+ {
+ foreach (GrantSource source in Enum.GetValues())
+ {
+ var message = GrantSourceMessages.For(source);
+ Assert.That(message, Is.Not.Null.And.Not.Empty,
+ $"GrantSource.{source} has no message defined.");
+ }
+ }
+}