diff --git a/src/api/SIGCM2.Domain/Entities/ProductPrice.cs b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs
new file mode 100644
index 0000000..47fdacf
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs
@@ -0,0 +1,25 @@
+namespace SIGCM2.Domain.Entities;
+
+///
+/// PRD-003 — Immutable record representing a price snapshot for a Product.
+/// Business dates are Cat2 (civil Argentina dates, DateOnly). Forward-only — no mutations.
+/// PriceValidTo = null means the row is the currently active price.
+///
+public sealed record ProductPrice(
+ long Id,
+ int ProductId,
+ decimal Price,
+ DateOnly PriceValidFrom,
+ DateOnly? PriceValidTo,
+ DateTime FechaCreacion)
+{
+ /// True if this row is the currently active price (PriceValidTo is null).
+ public bool IsActive => PriceValidTo is null;
+
+ ///
+ /// True if this price's window covers the given civil date (inclusive on both ends).
+ /// An active row (PriceValidTo = null) covers any date on or after PriceValidFrom.
+ ///
+ public bool CoversDate(DateOnly date)
+ => PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date);
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs
new file mode 100644
index 0000000..5a5819b
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs
@@ -0,0 +1,23 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly
+/// greater than the currently active price's PriceValidFrom. → HTTP 409
+///
+public sealed class ProductPriceForwardOnlyException : DomainException
+{
+ public int ProductId { get; }
+ public DateOnly NewPriceValidFrom { get; }
+ public DateOnly ActivePriceValidFrom { get; }
+
+ public ProductPriceForwardOnlyException(
+ int productId,
+ DateOnly newPriceValidFrom,
+ DateOnly activePriceValidFrom)
+ : base($"El nuevo PriceValidFrom ({newPriceValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al PriceValidFrom del precio activo ({activePriceValidFrom:yyyy-MM-dd}).")
+ {
+ ProductId = productId;
+ NewPriceValidFrom = newPriceValidFrom;
+ ActivePriceValidFrom = activePriceValidFrom;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs
new file mode 100644
index 0000000..6a2447e
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs
@@ -0,0 +1,19 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when a ProductPrice value fails domain business validation
+/// (e.g., Price <= 0, PriceValidFrom in the past). → HTTP 400
+/// Used as defense-in-depth alongside FluentValidation in the Application layer.
+///
+public sealed class ProductPriceInvalidException : DomainException
+{
+ public string Field { get; }
+ public string Reason { get; }
+
+ public ProductPriceInvalidException(string field, string reason)
+ : base($"Valor inválido para {field}: {reason}")
+ {
+ Field = field;
+ Reason = reason;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs
new file mode 100644
index 0000000..490f040
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs
@@ -0,0 +1,18 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when no ProductPrice row covers the requested date for the given product. → HTTP 404
+/// Consumers of IProductPricingService may throw this when GetPriceAtAsync returns null.
+///
+public sealed class ProductSinPrecioActivoException : DomainException
+{
+ public int ProductId { get; }
+ public DateOnly Date { get; }
+
+ public ProductSinPrecioActivoException(int productId, DateOnly date)
+ : base($"No existe precio registrado para el producto {productId} aplicable a la fecha {date:yyyy-MM-dd}.")
+ {
+ ProductId = productId;
+ Date = date;
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs
new file mode 100644
index 0000000..4f54e1d
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs
@@ -0,0 +1,128 @@
+using FluentAssertions;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Tests.Domain;
+
+///
+/// PRD-003 — Domain unit tests for ProductPrice-related exceptions.
+/// Verifies constructor props, message content, and DomainException inheritance.
+///
+public class ProductPriceExceptionTests
+{
+ // ── ProductPriceForwardOnlyException ─────────────────────────────────────
+
+ [Fact]
+ public void ProductPriceForwardOnlyException_SetsProperties()
+ {
+ var newPvf = new DateOnly(2026, 3, 1);
+ var activePvf = new DateOnly(2026, 4, 1);
+
+ var ex = new ProductPriceForwardOnlyException(productId: 42, newPvf, activePvf);
+
+ ex.ProductId.Should().Be(42);
+ ex.NewPriceValidFrom.Should().Be(newPvf);
+ ex.ActivePriceValidFrom.Should().Be(activePvf);
+ }
+
+ [Fact]
+ public void ProductPriceForwardOnlyException_MessageContainsKeyDates()
+ {
+ var newPvf = new DateOnly(2026, 3, 1);
+ var activePvf = new DateOnly(2026, 4, 1);
+
+ var ex = new ProductPriceForwardOnlyException(productId: 5, newPvf, activePvf);
+
+ ex.Message.Should().Contain("2026-03-01");
+ ex.Message.Should().Contain("2026-04-01");
+ }
+
+ [Fact]
+ public void ProductPriceForwardOnlyException_InheritsFromDomainException()
+ {
+ var ex = new ProductPriceForwardOnlyException(
+ productId: 1,
+ newPriceValidFrom: new DateOnly(2026, 1, 1),
+ activePriceValidFrom: new DateOnly(2026, 2, 1));
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── ProductPriceInvalidException ─────────────────────────────────────────
+
+ [Fact]
+ public void ProductPriceInvalidException_SetsProperties()
+ {
+ var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0");
+
+ ex.Field.Should().Be("Price");
+ ex.Reason.Should().Be("must be > 0");
+ }
+
+ [Fact]
+ public void ProductPriceInvalidException_MessageContainsFieldAndReason()
+ {
+ var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0");
+
+ ex.Message.Should().Contain("Price");
+ ex.Message.Should().Contain("must be > 0");
+ }
+
+ [Fact]
+ public void ProductPriceInvalidException_InheritsFromDomainException()
+ {
+ var ex = new ProductPriceInvalidException("Price", "invalid");
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── ProductSinPrecioActivoException ──────────────────────────────────────
+
+ [Fact]
+ public void ProductSinPrecioActivoException_SetsProperties()
+ {
+ var date = new DateOnly(2026, 4, 19);
+
+ var ex = new ProductSinPrecioActivoException(productId: 99, date);
+
+ ex.ProductId.Should().Be(99);
+ ex.Date.Should().Be(date);
+ }
+
+ [Fact]
+ public void ProductSinPrecioActivoException_MessageContainsProductIdAndDate()
+ {
+ var date = new DateOnly(2026, 4, 19);
+
+ var ex = new ProductSinPrecioActivoException(productId: 99, date);
+
+ ex.Message.Should().Contain("99");
+ ex.Message.Should().Contain("2026-04-19");
+ }
+
+ [Fact]
+ public void ProductSinPrecioActivoException_InheritsFromDomainException()
+ {
+ var ex = new ProductSinPrecioActivoException(1, new DateOnly(2026, 4, 1));
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── ProductNotFoundException — already exists, verify it works ────────────
+
+ [Fact]
+ public void ProductNotFoundException_SetsProductIdAndMessage()
+ {
+ var ex = new ProductNotFoundException(77);
+
+ ex.ProductId.Should().Be(77);
+ ex.Message.Should().Contain("77");
+ }
+
+ [Fact]
+ public void ProductNotFoundException_InheritsFromDomainException()
+ {
+ var ex = new ProductNotFoundException(1);
+
+ ex.Should().BeAssignableTo();
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
new file mode 100644
index 0000000..aa03dbc
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
@@ -0,0 +1,158 @@
+using FluentAssertions;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Tests.Domain;
+
+///
+/// PRD-003 — Domain unit tests for ProductPrice record.
+/// Covers: §REQ-4.1 (IsActive), §REQ-4.2 (CoversDate inclusive bounds),
+/// §REQ-4.4 (active row covers any date on or after PriceValidFrom).
+///
+public class ProductPriceTests
+{
+ private static ProductPrice MakePrice(
+ long id = 1,
+ int productId = 10,
+ decimal price = 1500.00m,
+ DateOnly? from = null,
+ DateOnly? to = null,
+ DateTime? fechaCreacion = null)
+ => new(
+ Id: id,
+ ProductId: productId,
+ Price: price,
+ PriceValidFrom: from ?? new DateOnly(2026, 4, 1),
+ PriceValidTo: to,
+ FechaCreacion: fechaCreacion ?? new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ // ── §REQ-4.1: IsActive ────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsActive_TrueWhenPriceValidToIsNull()
+ {
+ var price = MakePrice(to: null);
+
+ price.IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsActive_FalseWhenPriceValidToHasValue()
+ {
+ var price = MakePrice(to: new DateOnly(2026, 4, 30));
+
+ price.IsActive.Should().BeFalse();
+ }
+
+ // ── §REQ-4.2: CoversDate — inclusive lower bound ──────────────────────────
+
+ [Fact]
+ public void CoversDate_InclusiveLowerBound_ReturnsTrue()
+ {
+ // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidFrom.
+ var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30));
+
+ price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue();
+ }
+
+ // ── §REQ-4.2: CoversDate — inclusive upper bound ──────────────────────────
+
+ [Fact]
+ public void CoversDate_InclusiveUpperBound_ReturnsTrue()
+ {
+ // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidTo.
+ var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30));
+
+ price.CoversDate(new DateOnly(2026, 4, 30)).Should().BeTrue();
+ }
+
+ // ── §REQ-4.2: CoversDate — exclusive outside range ────────────────────────
+
+ [Fact]
+ public void CoversDate_DateBeforePriceValidFrom_ReturnsFalse()
+ {
+ var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20));
+
+ price.CoversDate(new DateOnly(2026, 4, 9)).Should().BeFalse();
+ }
+
+ [Fact]
+ public void CoversDate_DateAfterPriceValidTo_ReturnsFalse()
+ {
+ var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20));
+
+ price.CoversDate(new DateOnly(2026, 4, 21)).Should().BeFalse();
+ }
+
+ // ── §REQ-4.4: Active row (PriceValidTo = null) covers any date on or after PriceValidFrom ──
+
+ [Fact]
+ public void ActiveRow_CoversAnyDateOnOrAfterPvf()
+ {
+ // Active row: PriceValidFrom = 2026-04-01, PriceValidTo = null
+ var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null);
+
+ // On PriceValidFrom itself
+ price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue();
+ // Well in the future
+ price.CoversDate(new DateOnly(2030, 12, 31)).Should().BeTrue();
+ }
+
+ [Fact]
+ public void ActiveRow_DoesNotCoverDateBeforePvf()
+ {
+ var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null);
+
+ price.CoversDate(new DateOnly(2026, 3, 31)).Should().BeFalse();
+ }
+
+ // ── Record value equality (records compare by value) ─────────────────────
+
+ [Fact]
+ public void Equality_TwoIdenticalRecords_AreEqual()
+ {
+ var a = MakePrice(id: 1, productId: 10, price: 1500m,
+ from: new DateOnly(2026, 4, 1), to: null,
+ fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ var b = MakePrice(id: 1, productId: 10, price: 1500m,
+ from: new DateOnly(2026, 4, 1), to: null,
+ fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ a.Should().Be(b);
+ (a == b).Should().BeTrue();
+ }
+
+ [Fact]
+ public void Equality_DifferentId_AreNotEqual()
+ {
+ var a = MakePrice(id: 1);
+ var b = MakePrice(id: 2);
+
+ a.Should().NotBe(b);
+ }
+
+ // ── Immutability: record positional props are init-only (no regular set) ─
+
+ [Fact]
+ public void Record_PropertiesAreInitOnly_NotMutableSet()
+ {
+ // C# records expose init-only accessors, not regular set.
+ // IsExternalInit attribute on the setter's return type distinguishes init from set.
+ var type = typeof(ProductPrice);
+ var mutableSetters = type.GetProperties()
+ .Where(p =>
+ {
+ var setter = p.GetSetMethod(nonPublic: false);
+ if (setter is null) return false;
+ // init-only setters have a modreq on IsExternalInit in their signature;
+ // a simple way to detect them: they are NOT init-only when
+ // ReturnParameter.GetRequiredCustomModifiers() does NOT contain IsExternalInit.
+ var modifiers = setter.ReturnParameter.GetRequiredCustomModifiers();
+ bool isInitOnly = modifiers.Any(m => m.Name == "IsExternalInit");
+ return !isInitOnly; // only flag non-init setters as mutable
+ })
+ .ToList();
+
+ mutableSetters.Should().BeEmpty("ProductPrice must be immutable — all setters should be init-only.");
+ }
+}