diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs
new file mode 100644
index 0000000..0831183
--- /dev/null
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs
@@ -0,0 +1,40 @@
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Abstractions.Persistence;
+
+///
+/// PRD-003 — Write + query access to dbo.ProductPrices.
+/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
+///
+public interface IProductPriceRepository
+{
+ ///
+ /// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
+ /// Returns (newId, closedId?). Throws:
+ /// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
+ /// - ProductNotFoundException on SQL THROW 50404.
+ ///
+ Task<(long NewId, long? ClosedId)> AddAsync(
+ int productId,
+ decimal price,
+ DateOnly priceValidFrom,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
+ /// Returns empty list when the product has no price history.
+ ///
+ Task> GetByProductIdAsync(
+ int productId,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
+ /// civil date, or null if no row matches (no history, or date is before any recorded price).
+ /// Used by ProductPricingService.GetPriceAtAsync.
+ ///
+ Task GetActiveAsync(
+ int productId,
+ DateOnly date,
+ CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs
index d0e64ec..98f5384 100644
--- a/src/api/SIGCM2.Application/DependencyInjection.cs
+++ b/src/api/SIGCM2.Application/DependencyInjection.cs
@@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
+using SIGCM2.Application.Products.Prices;
+using SIGCM2.Application.Products.Prices.AddPrice;
+using SIGCM2.Application.Products.Prices.GetHistory;
+using SIGCM2.Application.Products.Pricing;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate;
@@ -182,6 +186,11 @@ public static class DependencyInjection
services.AddScoped, GetProductByIdQueryHandler>();
services.AddScoped>, ListProductsQueryHandler>();
+ // ProductPrices (PRD-003)
+ services.AddScoped, AddProductPriceCommandHandler>();
+ services.AddScoped>, GetProductPricesQueryHandler>();
+ services.AddScoped();
+
// ProductTypes (PRD-001)
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs
new file mode 100644
index 0000000..e2faef6
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Products.Prices.AddPrice;
+
+///
+/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
+/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
+///
+public sealed record AddProductPriceCommand(
+ int ProductId,
+ decimal Price,
+ DateOnly PriceValidFrom);
diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs
new file mode 100644
index 0000000..99861ff
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs
@@ -0,0 +1,84 @@
+using System.Transactions;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+using SIGCM2.Domain.Entities;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Products.Prices.AddPrice;
+
+///
+/// PRD-003 — Handler del comando AddProductPrice.
+/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) →
+/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) →
+/// tx.Complete() → construye response con GetByProductIdAsync.
+///
+public sealed class AddProductPriceCommandHandler
+ : ICommandHandler
+{
+ private readonly IProductPriceRepository _pricesRepo;
+ private readonly IProductRepository _productsRepo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public AddProductPriceCommandHandler(
+ IProductPriceRepository pricesRepo,
+ IProductRepository productsRepo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _pricesRepo = pricesRepo;
+ _productsRepo = productsRepo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(AddProductPriceCommand command)
+ {
+ // 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD).
+ var product = await _productsRepo.GetByIdAsync(command.ProductId)
+ ?? throw new ProductNotFoundException(command.ProductId);
+
+ if (!product.IsActive)
+ throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes
+
+ // 2. TX + SP + audit (fail-closed).
+ // El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
+ using var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled);
+
+ var (newId, closedId) = await _pricesRepo.AddAsync(
+ command.ProductId, command.Price, command.PriceValidFrom);
+
+ await _audit.LogAsync(
+ action: "product_price.created",
+ targetType: "ProductPrice",
+ targetId: newId.ToString(),
+ metadata: new
+ {
+ after = new
+ {
+ command.ProductId,
+ command.Price,
+ priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
+ },
+ closedPriceId = closedId
+ });
+
+ tx.Complete();
+
+ // 3. Compongo la respuesta post-commit con lectura de historial actualizado.
+ var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);
+ var created = prices.Single(p => p.Id == newId);
+ var closed = closedId.HasValue
+ ? prices.SingleOrDefault(p => p.Id == closedId.Value)
+ : null;
+
+ return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed));
+ }
+
+ private static ProductPriceDto ToDto(ProductPrice p)
+ => new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive);
+}
diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs
new file mode 100644
index 0000000..25ac827
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs
@@ -0,0 +1,29 @@
+using FluentValidation;
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Products.Prices.AddPrice;
+
+///
+/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
+/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
+/// FakeTimeProvider en tests garantiza determinismo.
+///
+public sealed class AddProductPriceCommandValidator : AbstractValidator
+{
+ public AddProductPriceCommandValidator(TimeProvider timeProvider)
+ {
+ var today = timeProvider.GetArgentinaToday();
+
+ RuleFor(x => x.ProductId)
+ .GreaterThan(0)
+ .WithMessage("ProductId debe ser un entero positivo.");
+
+ RuleFor(x => x.Price)
+ .GreaterThan(0m)
+ .WithMessage("El precio debe ser mayor a cero.");
+
+ RuleFor(x => x.PriceValidFrom)
+ .GreaterThanOrEqualTo(today)
+ .WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva.");
+ }
+}
diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs
new file mode 100644
index 0000000..669e04b
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs
@@ -0,0 +1,9 @@
+namespace SIGCM2.Application.Products.Prices.AddPrice;
+
+///
+/// PRD-003 — Respuesta del comando AddProductPrice.
+/// Closed es null si era el primer precio registrado para el producto.
+///
+public sealed record AddProductPriceResponse(
+ ProductPriceDto Created,
+ ProductPriceDto? Closed);
diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs
new file mode 100644
index 0000000..8dce568
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs
@@ -0,0 +1,8 @@
+namespace SIGCM2.Application.Products.Prices.GetHistory;
+
+///
+/// PRD-003 — Query para obtener el historial de precios de un Product.
+/// Devuelve lista ordenada descending por PriceValidFrom (activo primero).
+/// Lanza ProductNotFoundException si el producto no existe.
+///
+public sealed record GetProductPricesQuery(int ProductId);
diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs
new file mode 100644
index 0000000..c81e87c
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs
@@ -0,0 +1,42 @@
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Products.Prices;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Products.Prices.GetHistory;
+
+///
+/// PRD-003 — Handler de GetProductPricesQuery.
+/// Verifica que el producto exista (404 si no), luego retorna historial de precios
+/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY).
+/// Lista vacía es válida (nuevo producto sin precios registrados aún).
+///
+public sealed class GetProductPricesQueryHandler
+ : ICommandHandler>
+{
+ private readonly IProductPriceRepository _pricesRepo;
+ private readonly IProductRepository _productsRepo;
+
+ public GetProductPricesQueryHandler(
+ IProductPriceRepository pricesRepo,
+ IProductRepository productsRepo)
+ {
+ _pricesRepo = pricesRepo;
+ _productsRepo = productsRepo;
+ }
+
+ public async Task> Handle(GetProductPricesQuery query)
+ {
+ // Verifica existencia del producto (lanza 404 si no existe).
+ _ = await _productsRepo.GetByIdAsync(query.ProductId)
+ ?? throw new ProductNotFoundException(query.ProductId);
+
+ var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId);
+
+ return prices
+ .Select(p => new ProductPriceDto(
+ p.Id, p.ProductId, p.Price,
+ p.PriceValidFrom, p.PriceValidTo, p.IsActive))
+ .ToList();
+ }
+}
diff --git a/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs
new file mode 100644
index 0000000..36d6e63
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs
@@ -0,0 +1,13 @@
+namespace SIGCM2.Application.Products.Prices;
+
+///
+/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
+/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
+///
+public sealed record ProductPriceDto(
+ long Id,
+ int ProductId,
+ decimal Price,
+ DateOnly PriceValidFrom,
+ DateOnly? PriceValidTo,
+ bool IsActive);
diff --git a/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs
new file mode 100644
index 0000000..94b4306
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs
@@ -0,0 +1,18 @@
+namespace SIGCM2.Application.Products.Pricing;
+
+///
+/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2).
+/// Contrato forward para PRC-001 (tasación).
+///
+/// Retorna null si no existe historial de precios para el producto en la fecha indicada.
+/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException)
+/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices).
+///
+public interface IProductPricingService
+{
+ ///
+ /// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
+ /// o null si ningún registro de precio cubre esa fecha.
+ ///
+ Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs
new file mode 100644
index 0000000..c7819a5
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs
@@ -0,0 +1,24 @@
+using SIGCM2.Application.Abstractions.Persistence;
+
+namespace SIGCM2.Application.Products.Pricing;
+
+///
+/// PRD-003 — Implementación de IProductPricingService.
+/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil.
+///
+public sealed class ProductPricingService : IProductPricingService
+{
+ private readonly IProductPriceRepository _repo;
+
+ public ProductPricingService(IProductPriceRepository repo)
+ {
+ _repo = repo;
+ }
+
+ ///
+ public async Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default)
+ {
+ var price = await _repo.GetActiveAsync(productId, date, ct);
+ return price?.Price;
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs
new file mode 100644
index 0000000..78a4815
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs
@@ -0,0 +1,222 @@
+using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+using SIGCM2.Application.Products.Prices;
+using SIGCM2.Application.Products.Prices.AddPrice;
+using SIGCM2.Domain.Entities;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Tests.Products.Prices;
+
+///
+/// PRD-003 — AddProductPriceCommandHandler tests.
+/// Covers: §REQ-6.1 (happy path + audit), §REQ-6.2 (audit fail → rollback),
+/// §REQ-3.3 (producto inexistente → ProductNotFoundException),
+/// §REQ-2.2 (ForwardOnly propagation), §REQ-2.1 (response con Closed).
+/// NSubstitute, FakeTimeProvider.
+///
+public class AddProductPriceCommandHandlerTests
+{
+ private readonly IProductPriceRepository _pricesRepo = Substitute.For();
+ private readonly IProductRepository _productsRepo = Substitute.For();
+ private readonly IAuditLogger _audit = Substitute.For();
+ private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero));
+ private readonly AddProductPriceCommandHandler _handler;
+
+ private static readonly DateOnly Today = new(2026, 4, 19);
+ private static readonly DateOnly Tomorrow = new(2026, 4, 20);
+
+ // Producto activo de ejemplo
+ private static Product ActiveProduct(int id = 1) => Product.ForCreation(
+ "Test Product", medioId: 1, productTypeId: 2,
+ rubroId: null, basePrice: 100m, priceDurationDays: null,
+ TimeProvider.System);
+
+ // ProductPrice stub de retorno
+ private static ProductPrice MakePrice(long id, int productId, decimal price,
+ DateOnly pvf, DateOnly? pvt = null) =>
+ new(id, productId, price, pvf, pvt,
+ FechaCreacion: new DateTime(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc));
+
+ public AddProductPriceCommandHandlerTests()
+ {
+ // defaults: producto activo existe; AddAsync devuelve newId=10, closedId=null
+ _productsRepo.GetByIdAsync(1, Arg.Any())
+ .Returns(ActiveProduct(1));
+
+ _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((10L, (long?)null));
+
+ _pricesRepo.GetByProductIdAsync(1, Arg.Any())
+ .Returns(new List
+ {
+ MakePrice(10, 1, 150m, Today)
+ }.AsReadOnly() as IReadOnlyList);
+
+ _handler = new AddProductPriceCommandHandler(_pricesRepo, _productsRepo, _audit, _time);
+ }
+
+ private static AddProductPriceCommand ValidCmd(int productId = 1) => new(
+ ProductId: productId,
+ Price: 150m,
+ PriceValidFrom: Today);
+
+ // ── Happy path ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Handle_HappyPath_ReturnsCreatedDto()
+ {
+ // §REQ-1.1 happy path — primer precio (sin activo previo)
+ var result = await _handler.Handle(ValidCmd());
+
+ result.Created.Should().NotBeNull();
+ result.Created.Id.Should().Be(10);
+ result.Created.Price.Should().Be(150m);
+ result.Created.PriceValidFrom.Should().Be(Today);
+ result.Created.IsActive.Should().BeTrue();
+ result.Closed.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_CallsRepoAddAsync_Once()
+ {
+ await _handler.Handle(ValidCmd());
+
+ await _pricesRepo.Received(1).AddAsync(
+ 1, 150m, Today, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_LogsAuditEvent_ProductPriceCreated()
+ {
+ // §REQ-6.1 — audit "product_price.created" llamado exactamente una vez
+ await _handler.Handle(ValidCmd());
+
+ await _audit.Received(1).LogAsync(
+ action: "product_price.created",
+ targetType: "ProductPrice",
+ targetId: "10",
+ metadata: Arg.Any