From fb59d109f6329d603ae583df89ad803ec8e18362 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Fri, 30 Oct 2026 17:42:54 +0530
Subject: [PATCH 01/10] feat(domain): add household, recipient and milk rate
model
Add the Household, Recipient and MilkRate aggregates with typed UUIDv7 ids,
Money in whole paise, PricePerLitre with half-up rounding once per line, and
RateSchedule for rate lookups by date. DomainException can name the input a
rule is about. Every applicable invariant from the domain model has a test.
---
.../Common/DomainException.cs | 12 +
.../PureFreshMilk.Domain/Common/Identifier.cs | 13 +
.../src/PureFreshMilk.Domain/Common/Money.cs | 40 +++
.../PureFreshMilk.Domain/Common/NameRules.cs | 29 +++
.../Households/Household.cs | 82 +++++++
.../Households/HouseholdId.cs | 17 ++
.../PureFreshMilk.Domain/Rates/MilkRate.cs | 60 +++++
.../PureFreshMilk.Domain/Rates/MilkRateId.cs | 17 ++
.../Rates/PricePerLitre.cs | 48 ++++
.../Rates/RateSchedule.cs | 57 +++++
.../Recipients/Recipient.cs | 124 ++++++++++
.../Recipients/RecipientId.cs | 17 ++
.../Common/MoneyTests.cs | 59 +++++
.../Common/TypedIdTests.cs | 55 +++++
.../Households/HouseholdTests.cs | 149 ++++++++++++
.../Rates/MilkRateTests.cs | 79 ++++++
.../Rates/PricePerLitreTests.cs | 99 ++++++++
.../Rates/RateScheduleTests.cs | 84 +++++++
.../Recipients/RecipientTests.cs | 229 ++++++++++++++++++
19 files changed, 1270 insertions(+)
create mode 100644 backend/src/PureFreshMilk.Domain/Common/Identifier.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Common/Money.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Common/NameRules.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Households/Household.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
create mode 100644 backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs
create mode 100644 backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs
diff --git a/backend/src/PureFreshMilk.Domain/Common/DomainException.cs b/backend/src/PureFreshMilk.Domain/Common/DomainException.cs
index a0d13ef..5a39a4c 100644
--- a/backend/src/PureFreshMilk.Domain/Common/DomainException.cs
+++ b/backend/src/PureFreshMilk.Domain/Common/DomainException.cs
@@ -9,4 +9,16 @@ public DomainException(string message)
: base(message)
{
}
+
+ public DomainException(string message, string? field)
+ : base(message)
+ {
+ Field = field;
+ }
+
+ ///
+ /// The single input the broken rule is about, named as in the API (for example expectedMl),
+ /// or null when the rule concerns several inputs or none.
+ ///
+ public string? Field { get; }
}
diff --git a/backend/src/PureFreshMilk.Domain/Common/Identifier.cs b/backend/src/PureFreshMilk.Domain/Common/Identifier.cs
new file mode 100644
index 0000000..834ece8
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Common/Identifier.cs
@@ -0,0 +1,13 @@
+namespace PureFreshMilk.Domain.Common;
+
+///
+/// Shared rules for the typed ids: UUIDv7 values (ordered by time, so inserts stay index-friendly and
+/// ids cannot be guessed) that are never empty.
+///
+internal static class Identifier
+{
+ public static Guid Create(DateTimeOffset now) => Guid.CreateVersion7(now);
+
+ public static Guid Require(Guid value, string what) =>
+ value == Guid.Empty ? throw new DomainException($"{what} cannot be empty.") : value;
+}
diff --git a/backend/src/PureFreshMilk.Domain/Common/Money.cs b/backend/src/PureFreshMilk.Domain/Common/Money.cs
new file mode 100644
index 0000000..57e6450
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Common/Money.cs
@@ -0,0 +1,40 @@
+using System.Globalization;
+
+namespace PureFreshMilk.Domain.Common;
+
+///
+/// An amount of Indian rupees held as a signed whole number of paise, so that sums are exact and never
+/// suffer from floating-point rounding (see ADR 0004). INR is the only currency. Unlike
+/// it may be negative, because adjustments can be.
+///
+public readonly record struct Money : IComparable
+{
+ private Money(long paise) => Paise = paise;
+
+ public long Paise { get; }
+
+ public static Money Zero => default;
+
+ public static Money FromPaise(long paise) => new(paise);
+
+ public static Money operator +(Money left, Money right) =>
+ new(checked(left.Paise + right.Paise));
+
+ public static Money operator -(Money left, Money right) =>
+ new(checked(left.Paise - right.Paise));
+
+ public static Money operator -(Money value) => new(checked(-value.Paise));
+
+ public static bool operator <(Money left, Money right) => left.CompareTo(right) < 0;
+
+ public static bool operator >(Money left, Money right) => left.CompareTo(right) > 0;
+
+ public static bool operator <=(Money left, Money right) => left.CompareTo(right) <= 0;
+
+ public static bool operator >=(Money left, Money right) => left.CompareTo(right) >= 0;
+
+ public int CompareTo(Money other) => Paise.CompareTo(other.Paise);
+
+ public override string ToString() =>
+ string.Create(CultureInfo.InvariantCulture, $"{Paise} paise");
+}
diff --git a/backend/src/PureFreshMilk.Domain/Common/NameRules.cs b/backend/src/PureFreshMilk.Domain/Common/NameRules.cs
new file mode 100644
index 0000000..be640fc
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Common/NameRules.cs
@@ -0,0 +1,29 @@
+namespace PureFreshMilk.Domain.Common;
+
+/// The rule shared by every display name: trimmed, 1 to 80 characters, no control characters.
+internal static class NameRules
+{
+ public const int MaxLength = 80;
+
+ public static string Require(string? value, string field, string what)
+ {
+ var name = value?.Trim() ?? string.Empty;
+
+ if (name.Length == 0)
+ {
+ throw new DomainException($"{what} is required.", field);
+ }
+
+ if (name.Length > MaxLength)
+ {
+ throw new DomainException($"{what} can have at most {MaxLength} characters.", field);
+ }
+
+ if (name.Any(char.IsControl))
+ {
+ throw new DomainException($"{what} cannot contain control characters.", field);
+ }
+
+ return name;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Domain/Households/Household.cs b/backend/src/PureFreshMilk.Domain/Households/Household.cs
new file mode 100644
index 0000000..9c56a89
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Households/Household.cs
@@ -0,0 +1,82 @@
+using System.Text.RegularExpressions;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Households;
+
+///
+/// A household that buys milk. It owns the notion of "today": dates in this application are calendar
+/// dates in the household's own time zone (ADR 0004).
+///
+public sealed partial class Household
+{
+ public const string DefaultTimeZoneId = "Asia/Kolkata";
+
+ // Persistence constructor.
+ private Household()
+ {
+ }
+
+ public HouseholdId Id { get; private set; }
+
+ public string Name { get; private set; } = string.Empty;
+
+ /// An IANA time zone id such as Asia/Kolkata.
+ public string TimeZoneId { get; private set; } = string.Empty;
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ public DateTimeOffset UpdatedAt { get; private set; }
+
+ public static Household Create(string? name, string? timeZoneId, DateTimeOffset now)
+ {
+ var household = new Household { Id = HouseholdId.New(now), CreatedAt = now };
+ household.Apply(name, timeZoneId, now);
+ return household;
+ }
+
+ public void Update(string? name, string? timeZoneId, DateTimeOffset now) => Apply(name, timeZoneId, now);
+
+ /// The calendar date it is right now in the household's time zone.
+ public DateOnly Today(DateTimeOffset utcNow)
+ {
+ var zone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId);
+ return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, zone).DateTime);
+ }
+
+ private void Apply(string? name, string? timeZoneId, DateTimeOffset now)
+ {
+ // Check everything before changing anything, so a rejected update leaves the household as it was.
+ var validName = NameRules.Require(name, "name", "The household name");
+ var validTimeZoneId = RequireTimeZone(timeZoneId);
+
+ Name = validName;
+ TimeZoneId = validTimeZoneId;
+ UpdatedAt = now;
+ }
+
+ private static string RequireTimeZone(string? timeZoneId)
+ {
+ var id = timeZoneId?.Trim() ?? string.Empty;
+
+ // Only IANA ids are stored: Windows ids ("India Standard Time") would not resolve on Linux.
+ if (!IanaIdPattern().IsMatch(id) || id.Length > 64)
+ {
+ throw new DomainException(
+ "The time zone must be an IANA id such as Asia/Kolkata.", "timeZoneId");
+ }
+
+ try
+ {
+ _ = TimeZoneInfo.FindSystemTimeZoneById(id);
+ }
+ catch (Exception exception) when (exception is TimeZoneNotFoundException or InvalidTimeZoneException)
+ {
+ throw new DomainException($"The time zone '{id}' is not known.", "timeZoneId");
+ }
+
+ return id;
+ }
+
+ [GeneratedRegex(@"^[A-Za-z_]+(/[A-Za-z0-9_+\-]+)*$", RegexOptions.CultureInvariant)]
+ private static partial Regex IanaIdPattern();
+}
diff --git a/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs b/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs
new file mode 100644
index 0000000..6d9162f
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Households/HouseholdId.cs
@@ -0,0 +1,17 @@
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Households;
+
+/// Identifies a household. A distinct type so it cannot be passed where a recipient id is expected.
+public readonly record struct HouseholdId
+{
+ private HouseholdId(Guid value) => Value = value;
+
+ public Guid Value { get; }
+
+ public static HouseholdId New(DateTimeOffset now) => new(Identifier.Create(now));
+
+ public static HouseholdId From(Guid value) => new(Identifier.Require(value, "A household id"));
+
+ public override string ToString() => Value.ToString();
+}
diff --git a/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs b/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs
new file mode 100644
index 0000000..8d587b5
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Rates/MilkRate.cs
@@ -0,0 +1,60 @@
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Domain.Rates;
+
+///
+/// The price of a litre of milk for a household from a given day. A delivery is priced at the rate with
+/// the latest effective-from date on or before the delivery date (see ).
+///
+public sealed class MilkRate
+{
+ // Persistence constructor.
+ private MilkRate()
+ {
+ }
+
+ public MilkRateId Id { get; private set; }
+
+ public HouseholdId HouseholdId { get; private set; }
+
+ /// The first day this price applies.
+ public DateOnly EffectiveFrom { get; private set; }
+
+ public PricePerLitre Price { get; private set; }
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ public DateTimeOffset UpdatedAt { get; private set; }
+
+ public static MilkRate Create(
+ HouseholdId householdId, DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now)
+ {
+ var rate = new MilkRate
+ {
+ Id = MilkRateId.New(now),
+ HouseholdId = householdId,
+ CreatedAt = now,
+ };
+ rate.Apply(effectiveFrom, price, now);
+ return rate;
+ }
+
+ public void Change(DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now) =>
+ Apply(effectiveFrom, price, now);
+
+ private void Apply(DateOnly effectiveFrom, PricePerLitre price, DateTimeOffset now)
+ {
+ if (effectiveFrom == default)
+ {
+ throw new DomainException("The date the price starts on is required.", "effectiveFrom");
+ }
+
+ // A default PricePerLitre (0 paise) bypasses FromPaise, so check the value again.
+ _ = PricePerLitre.FromPaise(price.PaisePerLitre);
+
+ EffectiveFrom = effectiveFrom;
+ Price = price;
+ UpdatedAt = now;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs b/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs
new file mode 100644
index 0000000..3810283
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Rates/MilkRateId.cs
@@ -0,0 +1,17 @@
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Rates;
+
+/// Identifies a milk rate.
+public readonly record struct MilkRateId
+{
+ private MilkRateId(Guid value) => Value = value;
+
+ public Guid Value { get; }
+
+ public static MilkRateId New(DateTimeOffset now) => new(Identifier.Create(now));
+
+ public static MilkRateId From(Guid value) => new(Identifier.Require(value, "A milk rate id"));
+
+ public override string ToString() => Value.ToString();
+}
diff --git a/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs b/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs
new file mode 100644
index 0000000..8d08d51
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Rates/PricePerLitre.cs
@@ -0,0 +1,48 @@
+using System.Globalization;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Rates;
+
+///
+/// A price for one litre of milk, in whole paise (ADR 0004). The upper bound keeps every amount
+/// computed from it far inside the range of a .
+///
+public readonly record struct PricePerLitre : IComparable
+{
+ public const long MinPaise = 1;
+ public const long MaxPaise = 1_000_000;
+
+ private PricePerLitre(long paisePerLitre) => PaisePerLitre = paisePerLitre;
+
+ public long PaisePerLitre { get; }
+
+ public static PricePerLitre FromPaise(long paisePerLitre)
+ {
+ if (paisePerLitre is < MinPaise or > MaxPaise)
+ {
+ throw new DomainException(
+ $"A price must be between {MinPaise} and {MaxPaise} paise a litre (got {paisePerLitre.ToString(CultureInfo.InvariantCulture)}).",
+ "pricePerLitrePaise");
+ }
+
+ return new PricePerLitre(paisePerLitre);
+ }
+
+ ///
+ /// The cost of at this price: millilitres × paise per litre ÷ 1000,
+ /// rounded half-up to a whole paisa. Round once per bill line, never per delivery, so pass the
+ /// line's total quantity (ADR 0004).
+ ///
+ public Money CostOf(Quantity quantity)
+ {
+ var thousandthsOfPaisa = checked((long)quantity.Millilitres * PaisePerLitre);
+ const long halfOfDivisor = Quantity.MillilitresPerLitre / 2;
+
+ return Money.FromPaise(checked(thousandthsOfPaisa + halfOfDivisor) / Quantity.MillilitresPerLitre);
+ }
+
+ public int CompareTo(PricePerLitre other) => PaisePerLitre.CompareTo(other.PaisePerLitre);
+
+ public override string ToString() =>
+ string.Create(CultureInfo.InvariantCulture, $"{PaisePerLitre} paise a litre");
+}
diff --git a/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs b/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs
new file mode 100644
index 0000000..c777c2b
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Rates/RateSchedule.cs
@@ -0,0 +1,57 @@
+using System.Globalization;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Rates;
+
+///
+/// One household's rates, ordered by effective-from date. Answers "which price applies on this day"
+/// without a database, so the rule is unit-testable.
+///
+public sealed class RateSchedule
+{
+ private readonly MilkRate[] rates;
+
+ public RateSchedule(IEnumerable rates)
+ {
+ this.rates = [.. rates.OrderBy(rate => rate.EffectiveFrom)];
+
+ for (var index = 1; index < this.rates.Length; index++)
+ {
+ if (this.rates[index].EffectiveFrom == this.rates[index - 1].EffectiveFrom)
+ {
+ throw new DomainException(
+ $"Two rates cannot start on the same date ({this.rates[index].EffectiveFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}).",
+ "effectiveFrom");
+ }
+
+ if (this.rates[index].HouseholdId != this.rates[0].HouseholdId)
+ {
+ throw new DomainException("A rate schedule belongs to a single household.");
+ }
+ }
+ }
+
+ /// The rates, earliest first.
+ public IReadOnlyList Rates => rates;
+
+ ///
+ /// The rate in force on : the one with the latest effective-from date on or before it,
+ /// or null before the first rate (such a day has no price; it is never priced at zero).
+ ///
+ public MilkRate? RateOn(DateOnly date)
+ {
+ MilkRate? inForce = null;
+
+ foreach (var rate in rates)
+ {
+ if (rate.EffectiveFrom > date)
+ {
+ break;
+ }
+
+ inForce = rate;
+ }
+
+ return inForce;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
new file mode 100644
index 0000000..8989677
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Domain.Recipients;
+
+///
+/// Someone in the household who receives a daily milk delivery. Never deleted: a recipient who stops
+/// receiving milk is archived, so their history stays (ADR 0004).
+///
+public sealed class Recipient
+{
+ public const int MinExpectedMillilitres = 1;
+ public const int MaxExpectedMillilitres = 20_000;
+
+ // Persistence constructor.
+ private Recipient()
+ {
+ }
+
+ public RecipientId Id { get; private set; }
+
+ public HouseholdId HouseholdId { get; private set; }
+
+ public string Name { get; private set; } = string.Empty;
+
+ /// The usual daily quantity. Applied when a delivery is recorded without one.
+ public Quantity ExpectedQuantity { get; private set; }
+
+ /// The first day a delivery is expected.
+ public DateOnly StartsOn { get; private set; }
+
+ /// The first day on which no delivery is expected, or null while the recipient is not archived.
+ public DateOnly? ArchivedFrom { get; private set; }
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ public DateTimeOffset UpdatedAt { get; private set; }
+
+ public static Recipient Add(
+ HouseholdId householdId, string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now)
+ {
+ var recipient = new Recipient
+ {
+ Id = RecipientId.New(now),
+ HouseholdId = householdId,
+ CreatedAt = now,
+ };
+ recipient.Apply(name, expectedQuantity, startsOn, now);
+ return recipient;
+ }
+
+ public void Update(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) =>
+ Apply(name, expectedQuantity, startsOn, now);
+
+ /// Stops deliveries from onwards. The days before it keep their history.
+ public void Archive(DateOnly from, DateTimeOffset now)
+ {
+ if (ArchivedFrom is not null)
+ {
+ throw new DomainException("The recipient is already archived.");
+ }
+
+ if (from <= StartsOn)
+ {
+ throw new DomainException(
+ $"A recipient must be active for at least one day, so the archive date must be after the start date ({FormatDate(StartsOn)}).",
+ "archivedFrom");
+ }
+
+ ArchivedFrom = from;
+ UpdatedAt = now;
+ }
+
+ /// Brings an archived recipient back. Does nothing if the recipient is not archived.
+ public void Unarchive(DateTimeOffset now)
+ {
+ if (ArchivedFrom is null)
+ {
+ return;
+ }
+
+ ArchivedFrom = null;
+ UpdatedAt = now;
+ }
+
+ /// Whether a delivery is expected on : from the start date up to the day before archiving.
+ public bool IsActiveOn(DateOnly date) =>
+ date >= StartsOn && (ArchivedFrom is null || date < ArchivedFrom);
+
+ /// Whether the recipient has been archived as of (an archive date in the future still counts as active).
+ public bool IsArchivedOn(DateOnly today) => ArchivedFrom is { } from && from <= today;
+
+ private void Apply(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now)
+ {
+ // Check everything before changing anything, so a rejected update leaves the recipient as it was.
+ var validName = NameRules.Require(name, "name", "The recipient's name");
+
+ if (expectedQuantity.Millilitres is < MinExpectedMillilitres or > MaxExpectedMillilitres)
+ {
+ throw new DomainException(
+ $"The usual quantity must be between {MinExpectedMillilitres} ml and {MaxExpectedMillilitres} ml (got {expectedQuantity.Millilitres} ml).",
+ "expectedMl");
+ }
+
+ if (startsOn == default)
+ {
+ throw new DomainException("The start date is required.", "startsOn");
+ }
+
+ if (ArchivedFrom is { } archivedFrom && startsOn >= archivedFrom)
+ {
+ throw new DomainException(
+ $"The start date must be before the archive date ({FormatDate(archivedFrom)}).", "startsOn");
+ }
+
+ Name = validName;
+ ExpectedQuantity = expectedQuantity;
+ StartsOn = startsOn;
+ UpdatedAt = now;
+ }
+
+ private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+}
diff --git a/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs b/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs
new file mode 100644
index 0000000..bdb7002
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Recipients/RecipientId.cs
@@ -0,0 +1,17 @@
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Recipients;
+
+/// Identifies a recipient. A distinct type so it cannot be passed where a household id is expected.
+public readonly record struct RecipientId
+{
+ private RecipientId(Guid value) => Value = value;
+
+ public Guid Value { get; }
+
+ public static RecipientId New(DateTimeOffset now) => new(Identifier.Create(now));
+
+ public static RecipientId From(Guid value) => new(Identifier.Require(value, "A recipient id"));
+
+ public override string ToString() => Value.ToString();
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs
new file mode 100644
index 0000000..ceeb5fc
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Common/MoneyTests.cs
@@ -0,0 +1,59 @@
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Domain.Tests.Common;
+
+public sealed class MoneyTests
+{
+ [Fact]
+ public void Default_money_is_zero()
+ {
+ Assert.Equal(0, default(Money).Paise);
+ Assert.Equal(Money.Zero, default);
+ }
+
+ [Fact]
+ public void Money_adds_and_subtracts_exactly()
+ {
+ var total = Money.FromPaise(46_875) + Money.FromPaise(15) - Money.FromPaise(90);
+
+ Assert.Equal(Money.FromPaise(46_800), total);
+ }
+
+ [Fact]
+ public void Money_can_be_negative_because_adjustments_can_be()
+ {
+ Assert.Equal(-250, Money.FromPaise(-250).Paise);
+ Assert.Equal(Money.FromPaise(-250), -Money.FromPaise(250));
+ Assert.Equal(Money.FromPaise(-100), Money.FromPaise(50) - Money.FromPaise(150));
+ }
+
+ [Fact]
+ [Trait("Invariant", "B-4")]
+ public void Arithmetic_that_overflows_throws()
+ {
+ var largest = Money.FromPaise(long.MaxValue);
+ var smallest = Money.FromPaise(long.MinValue);
+
+ Assert.Throws(() => largest + Money.FromPaise(1));
+ Assert.Throws(() => smallest - Money.FromPaise(1));
+ Assert.Throws(() => -smallest);
+ }
+
+ [Fact]
+ public void Money_compares_by_amount()
+ {
+ var small = Money.FromPaise(100);
+ var large = Money.FromPaise(200);
+
+ Assert.True(small < large);
+ Assert.True(large > small);
+ Assert.True(small <= Money.FromPaise(100));
+ Assert.True(large >= small);
+ }
+
+ [Fact]
+ public void ToString_shows_paise()
+ {
+ Assert.Equal("6250 paise", Money.FromPaise(6250).ToString());
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs
new file mode 100644
index 0000000..cfa8953
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Common/TypedIdTests.cs
@@ -0,0 +1,55 @@
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Domain.Tests.Common;
+
+public sealed class TypedIdTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void New_ids_are_non_empty_version_7_uuids()
+ {
+ Guid[] values = [HouseholdId.New(Now).Value, RecipientId.New(Now).Value, MilkRateId.New(Now).Value];
+
+ Assert.All(values, value =>
+ {
+ Assert.NotEqual(Guid.Empty, value);
+ Assert.Equal(7, value.Version);
+ });
+ }
+
+ [Fact]
+ public void New_ids_are_unique()
+ {
+ Assert.NotEqual(RecipientId.New(Now), RecipientId.New(Now));
+ }
+
+ [Fact]
+ public void From_round_trips_a_value()
+ {
+ var value = Guid.CreateVersion7(Now);
+
+ Assert.Equal(value, HouseholdId.From(value).Value);
+ Assert.Equal(value, RecipientId.From(value).Value);
+ Assert.Equal(value, MilkRateId.From(value).Value);
+ }
+
+ [Fact]
+ public void From_rejects_an_empty_guid()
+ {
+ Assert.Throws(() => HouseholdId.From(Guid.Empty));
+ Assert.Throws(() => RecipientId.From(Guid.Empty));
+ Assert.Throws(() => MilkRateId.From(Guid.Empty));
+ }
+
+ [Fact]
+ public void ToString_is_the_guid()
+ {
+ var value = Guid.CreateVersion7(Now);
+
+ Assert.Equal(value.ToString(), RecipientId.From(value).ToString());
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs
new file mode 100644
index 0000000..4ffc7b7
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Households/HouseholdTests.cs
@@ -0,0 +1,149 @@
+using System.Globalization;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Domain.Tests.Households;
+
+public sealed class HouseholdTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void Create_stores_the_details_and_timestamps()
+ {
+ var household = Household.Create(" The Example Household ", "Asia/Kolkata", Now);
+
+ Assert.NotEqual(Guid.Empty, household.Id.Value);
+ Assert.Equal("The Example Household", household.Name);
+ Assert.Equal("Asia/Kolkata", household.TimeZoneId);
+ Assert.Equal(Now, household.CreatedAt);
+ Assert.Equal(Now, household.UpdatedAt);
+ }
+
+ [Theory]
+ [Trait("Invariant", "H-1")]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Create_rejects_a_missing_name(string? name)
+ {
+ var exception = Assert.Throws(() => Household.Create(name, "Asia/Kolkata", Now));
+
+ Assert.Equal("name", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-1")]
+ public void Name_can_have_80_characters_but_not_81()
+ {
+ Assert.Equal(80, Household.Create(new string('a', 80), "Asia/Kolkata", Now).Name.Length);
+
+ var exception = Assert.Throws(() => Household.Create(new string('a', 81), "Asia/Kolkata", Now));
+ Assert.Equal("name", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-1")]
+ public void Name_rejects_control_characters()
+ {
+ Assert.Throws(() => Household.Create("Line one\nLine two", "Asia/Kolkata", Now));
+ }
+
+ [Theory]
+ [Trait("Invariant", "H-2")]
+ [InlineData("Asia/Kolkata")]
+ [InlineData("Europe/London")]
+ [InlineData("America/Argentina/Buenos_Aires")]
+ [InlineData("UTC")]
+ public void Create_accepts_valid_iana_time_zones(string timeZoneId)
+ {
+ Assert.Equal(timeZoneId, Household.Create("Home", timeZoneId, Now).TimeZoneId);
+ }
+
+ [Theory]
+ [Trait("Invariant", "H-2")]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Mars/Olympus_Mons")]
+ [InlineData("India Standard Time")]
+ [InlineData("UTC-11")]
+ [InlineData("Asia/")]
+ [InlineData("../etc/passwd")]
+ public void Create_rejects_unknown_or_non_iana_time_zones(string? timeZoneId)
+ {
+ var exception = Assert.Throws(() => Household.Create("Home", timeZoneId, Now));
+
+ Assert.Equal("timeZoneId", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-2")]
+ public void Time_zone_ids_longer_than_64_characters_are_rejected()
+ {
+ Assert.Throws(() => Household.Create("Home", "Asia/" + new string('a', 70), Now));
+ }
+
+ [Theory]
+ [Trait("Invariant", "H-3")]
+ [InlineData("2026-10-30T18:29:00Z", "2026-10-30")]
+ [InlineData("2026-10-30T18:30:00Z", "2026-10-31")]
+ [InlineData("2026-10-30T00:00:00Z", "2026-10-30")]
+ public void Today_follows_the_household_time_zone(string utcNow, string expectedDate)
+ {
+ var household = Household.Create("Home", "Asia/Kolkata", Now);
+
+ var today = household.Today(DateTimeOffset.Parse(utcNow, CultureInfo.InvariantCulture));
+
+ Assert.Equal(DateOnly.Parse(expectedDate, CultureInfo.InvariantCulture), today);
+ }
+
+ [Theory]
+ [Trait("Invariant", "H-3")]
+ [InlineData("2026-10-30T03:59:00Z", "2026-10-29")]
+ [InlineData("2026-10-30T04:00:00Z", "2026-10-30")]
+ public void Today_accounts_for_daylight_saving_time(string utcNow, string expectedDate)
+ {
+ // New York is still on daylight saving time (UTC-4) on 30 October 2026.
+ var household = Household.Create("Home", "America/New_York", Now);
+
+ var today = household.Today(DateTimeOffset.Parse(utcNow, CultureInfo.InvariantCulture));
+
+ Assert.Equal(DateOnly.Parse(expectedDate, CultureInfo.InvariantCulture), today);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-4")]
+ public void Asia_Kolkata_resolves_on_this_platform()
+ {
+ // Fails loudly where time zone data is missing, for example in a slim container image.
+ var zone = TimeZoneInfo.FindSystemTimeZoneById(Household.DefaultTimeZoneId);
+
+ Assert.Equal(TimeSpan.FromHours(5.5), zone.BaseUtcOffset);
+ }
+
+ [Fact]
+ public void Update_changes_the_details_and_the_timestamp()
+ {
+ var household = Household.Create("Home", "Asia/Kolkata", Now);
+ var later = Now.AddHours(1);
+
+ household.Update("New name", "Europe/London", later);
+
+ Assert.Equal("New name", household.Name);
+ Assert.Equal("Europe/London", household.TimeZoneId);
+ Assert.Equal(Now, household.CreatedAt);
+ Assert.Equal(later, household.UpdatedAt);
+ }
+
+ [Fact]
+ public void A_rejected_update_leaves_the_household_unchanged()
+ {
+ var household = Household.Create("Home", "Asia/Kolkata", Now);
+
+ Assert.Throws(() => household.Update("New name", "Mars/Olympus_Mons", Now.AddHours(1)));
+
+ Assert.Equal("Home", household.Name);
+ Assert.Equal("Asia/Kolkata", household.TimeZoneId);
+ Assert.Equal(Now, household.UpdatedAt);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs
new file mode 100644
index 0000000..845344d
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/MilkRateTests.cs
@@ -0,0 +1,79 @@
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Domain.Tests.Rates;
+
+public sealed class MilkRateTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+ private static readonly HouseholdId Household = HouseholdId.New(Now);
+
+ [Fact]
+ public void Create_stores_the_details()
+ {
+ var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now);
+
+ Assert.NotEqual(Guid.Empty, rate.Id.Value);
+ Assert.Equal(Household, rate.HouseholdId);
+ Assert.Equal(new DateOnly(2026, 10, 1), rate.EffectiveFrom);
+ Assert.Equal(6250, rate.Price.PaisePerLitre);
+ Assert.Equal(Now, rate.CreatedAt);
+ Assert.Equal(Now, rate.UpdatedAt);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-1")]
+ public void The_effective_from_date_is_required()
+ {
+ var exception = Assert.Throws(
+ () => MilkRate.Create(Household, default, PricePerLitre.FromPaise(6250), Now));
+
+ Assert.Equal("effectiveFrom", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-1")]
+ public void A_default_price_of_zero_is_rejected()
+ {
+ var exception = Assert.Throws(
+ () => MilkRate.Create(Household, new DateOnly(2026, 10, 1), default, Now));
+
+ Assert.Equal("pricePerLitrePaise", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-4")]
+ public void A_rate_can_start_in_the_past()
+ {
+ var rate = MilkRate.Create(Household, new DateOnly(2020, 1, 1), PricePerLitre.FromPaise(4000), Now);
+
+ Assert.Equal(new DateOnly(2020, 1, 1), rate.EffectiveFrom);
+ }
+
+ [Fact]
+ public void Change_updates_the_date_price_and_timestamp()
+ {
+ var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now);
+ var later = Now.AddHours(1);
+
+ rate.Change(new DateOnly(2026, 10, 5), PricePerLitre.FromPaise(6400), later);
+
+ Assert.Equal(new DateOnly(2026, 10, 5), rate.EffectiveFrom);
+ Assert.Equal(6400, rate.Price.PaisePerLitre);
+ Assert.Equal(Now, rate.CreatedAt);
+ Assert.Equal(later, rate.UpdatedAt);
+ }
+
+ [Fact]
+ public void A_rejected_change_leaves_the_rate_unchanged()
+ {
+ var rate = MilkRate.Create(Household, new DateOnly(2026, 10, 1), PricePerLitre.FromPaise(6250), Now);
+
+ Assert.Throws(() => rate.Change(default, PricePerLitre.FromPaise(6400), Now.AddHours(1)));
+
+ Assert.Equal(new DateOnly(2026, 10, 1), rate.EffectiveFrom);
+ Assert.Equal(6250, rate.Price.PaisePerLitre);
+ Assert.Equal(Now, rate.UpdatedAt);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs
new file mode 100644
index 0000000..396e2b8
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/PricePerLitreTests.cs
@@ -0,0 +1,99 @@
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Domain.Tests.Rates;
+
+public sealed class PricePerLitreTests
+{
+ [Theory]
+ [Trait("Invariant", "T-1")]
+ [InlineData(1)]
+ [InlineData(6250)]
+ [InlineData(1_000_000)]
+ public void A_price_from_1_to_1000000_paise_a_litre_is_accepted(long paise)
+ {
+ Assert.Equal(paise, PricePerLitre.FromPaise(paise).PaisePerLitre);
+ }
+
+ [Theory]
+ [Trait("Invariant", "T-1")]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(1_000_001)]
+ [InlineData(long.MaxValue)]
+ public void Zero_negative_and_oversized_prices_are_rejected(long paise)
+ {
+ var exception = Assert.Throws(() => PricePerLitre.FromPaise(paise));
+
+ Assert.Equal("pricePerLitrePaise", exception.Field);
+ }
+
+ [Theory]
+ [Trait("Invariant", "B-3")]
+ [InlineData(250, 6250, 1563)] // 1562.5 paise: the exact midpoint rounds up
+ [InlineData(1000, 6250, 6250)]
+ [InlineData(500, 3, 2)] // 1.5 paise: the exact midpoint rounds up
+ [InlineData(499, 3, 1)] // 1.497 paise
+ [InlineData(333, 3, 1)] // 0.999 paise
+ [InlineData(1, 1, 0)] // 0.001 paise
+ [InlineData(0, 6250, 0)]
+ [InlineData(7500, 6250, 46_875)]
+ public void The_cost_is_millilitres_times_price_over_1000_rounded_half_up(
+ int millilitres, long pricePaise, long expectedPaise)
+ {
+ var cost = PricePerLitre.FromPaise(pricePaise).CostOf(Quantity.FromMillilitres(millilitres));
+
+ Assert.Equal(expectedPaise, cost.Paise);
+ }
+
+ [Fact]
+ [Trait("Invariant", "B-3")]
+ public void Rounding_once_per_line_differs_from_rounding_every_delivery()
+ {
+ var price = PricePerLitre.FromPaise(6250);
+
+ // 30 days of 250 ml: one line of 7,500 ml, as a calculator would give it.
+ var once = price.CostOf(Quantity.FromMillilitres(30 * 250));
+ // The rejected alternative: rounding every delivery overstates the bill by 15 paise.
+ var perDelivery = Enumerable.Range(0, 30).Aggregate(
+ Money.Zero, (sum, _) => sum + price.CostOf(Quantity.FromMillilitres(250)));
+
+ Assert.Equal(Money.FromPaise(46_875), once);
+ Assert.Equal(Money.FromPaise(46_890), perDelivery);
+ }
+
+ [Fact]
+ [Trait("Invariant", "B-2")]
+ public void A_rate_change_splits_a_month_into_one_line_per_rate()
+ {
+ // 1 L a day: ₹60.00 until 14 October (14 days), ₹64.00 from 15 October (17 days).
+ var before = PricePerLitre.FromPaise(6000).CostOf(Quantity.FromMillilitres(14 * 1000));
+ var after = PricePerLitre.FromPaise(6400).CostOf(Quantity.FromMillilitres(17 * 1000));
+
+ Assert.Equal(Money.FromPaise(84_000), before);
+ Assert.Equal(Money.FromPaise(108_800), after);
+ Assert.Equal(Money.FromPaise(192_800), before + after);
+ }
+
+ [Fact]
+ [Trait("Invariant", "B-4")]
+ public void The_largest_quantity_at_the_largest_price_does_not_overflow()
+ {
+ var cost = PricePerLitre.FromPaise(PricePerLitre.MaxPaise).CostOf(Quantity.FromMillilitres(int.MaxValue));
+
+ Assert.Equal(2_147_483_647_000L, cost.Paise);
+ }
+
+ [Fact]
+ public void Prices_compare_by_amount()
+ {
+ Assert.True(PricePerLitre.FromPaise(6000).CompareTo(PricePerLitre.FromPaise(6400)) < 0);
+ Assert.Equal(PricePerLitre.FromPaise(6000), PricePerLitre.FromPaise(6000));
+ }
+
+ [Fact]
+ public void ToString_shows_paise_a_litre()
+ {
+ Assert.Equal("6250 paise a litre", PricePerLitre.FromPaise(6250).ToString());
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs
new file mode 100644
index 0000000..962e5d9
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Rates/RateScheduleTests.cs
@@ -0,0 +1,84 @@
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Domain.Tests.Rates;
+
+public sealed class RateScheduleTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+ private static readonly HouseholdId Household = HouseholdId.New(Now);
+
+ private static MilkRate Rate(DateOnly from, long paise, HouseholdId? household = null) =>
+ MilkRate.Create(household ?? Household, from, PricePerLitre.FromPaise(paise), Now);
+
+ private static readonly MilkRate October = Rate(new DateOnly(2026, 10, 1), 6000);
+ private static readonly MilkRate Mid = Rate(new DateOnly(2026, 10, 15), 6400);
+
+ [Fact]
+ [Trait("Invariant", "T-2")]
+ public void There_is_no_rate_before_the_first_one()
+ {
+ var schedule = new RateSchedule([October, Mid]);
+
+ Assert.Null(schedule.RateOn(new DateOnly(2026, 9, 30)));
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-2")]
+ public void An_empty_schedule_has_no_rate_on_any_day()
+ {
+ Assert.Null(new RateSchedule([]).RateOn(new DateOnly(2026, 10, 30)));
+ }
+
+ [Theory]
+ [Trait("Invariant", "T-2")]
+ [InlineData("2026-10-01", 6000)] // the first rate applies on its own effective-from day
+ [InlineData("2026-10-14", 6000)] // the old rate applies the day before the change
+ [InlineData("2026-10-15", 6400)] // the new rate applies on its effective-from day
+ [InlineData("2026-12-31", 6400)] // and keeps applying until a later rate starts
+ public void The_rate_in_force_is_the_latest_one_starting_on_or_before_the_day(string date, long expectedPaise)
+ {
+ var schedule = new RateSchedule([October, Mid]);
+
+ var rate = schedule.RateOn(DateOnly.Parse(date, System.Globalization.CultureInfo.InvariantCulture));
+
+ Assert.NotNull(rate);
+ Assert.Equal(expectedPaise, rate.Price.PaisePerLitre);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-2")]
+ public void The_order_rates_were_added_in_does_not_matter()
+ {
+ // A back-dated rate is added last but still governs the days it covers.
+ var backDated = Rate(new DateOnly(2026, 9, 1), 5500);
+ var inAddedOrder = new RateSchedule([October, Mid, backDated]);
+ var reversed = new RateSchedule([backDated, Mid, October]);
+
+ Assert.Equal(5500, inAddedOrder.RateOn(new DateOnly(2026, 9, 15))?.Price.PaisePerLitre);
+ Assert.Equal(5500, reversed.RateOn(new DateOnly(2026, 9, 15))?.Price.PaisePerLitre);
+ Assert.Equal(
+ inAddedOrder.Rates.Select(rate => rate.EffectiveFrom),
+ reversed.Rates.Select(rate => rate.EffectiveFrom));
+ Assert.Equal(new DateOnly(2026, 9, 1), inAddedOrder.Rates[0].EffectiveFrom);
+ }
+
+ [Fact]
+ public void Two_rates_cannot_start_on_the_same_day()
+ {
+ var duplicate = Rate(new DateOnly(2026, 10, 1), 7000);
+
+ var exception = Assert.Throws(() => new RateSchedule([October, duplicate]));
+
+ Assert.Equal("effectiveFrom", exception.Field);
+ }
+
+ [Fact]
+ public void A_schedule_cannot_mix_households()
+ {
+ var other = Rate(new DateOnly(2026, 11, 1), 6500, HouseholdId.New(Now));
+
+ Assert.Throws(() => new RateSchedule([October, other]));
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs b/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs
new file mode 100644
index 0000000..d016ffa
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Domain.Tests/Recipients/RecipientTests.cs
@@ -0,0 +1,229 @@
+using System.Globalization;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Domain.Tests.Recipients;
+
+public sealed class RecipientTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+ private static readonly HouseholdId Household = HouseholdId.New(Now);
+ private static readonly DateOnly Start = new(2026, 10, 1);
+
+ private static Recipient NewRecipient(string name = "Recipient A", int millilitres = 1000) =>
+ Recipient.Add(Household, name, Quantity.FromMillilitres(millilitres), Start, Now);
+
+ [Fact]
+ public void Add_stores_the_details_and_starts_active()
+ {
+ var recipient = Recipient.Add(Household, " Recipient A ", Quantity.FromMillilitres(1000), Start, Now);
+
+ Assert.NotEqual(Guid.Empty, recipient.Id.Value);
+ Assert.Equal(Household, recipient.HouseholdId);
+ Assert.Equal("Recipient A", recipient.Name);
+ Assert.Equal(1000, recipient.ExpectedQuantity.Millilitres);
+ Assert.Equal(Start, recipient.StartsOn);
+ Assert.Null(recipient.ArchivedFrom);
+ Assert.Equal(Now, recipient.CreatedAt);
+ Assert.Equal(Now, recipient.UpdatedAt);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-1")]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Add_rejects_a_missing_name(string? name)
+ {
+ var exception = Assert.Throws(
+ () => Recipient.Add(Household, name, Quantity.FromMillilitres(1000), Start, Now));
+
+ Assert.Equal("name", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-1")]
+ public void Name_can_have_80_characters_but_not_81()
+ {
+ Assert.Equal(80, NewRecipient(new string('a', 80)).Name.Length);
+
+ var exception = Assert.Throws(() => NewRecipient(new string('a', 81)));
+ Assert.Equal("name", exception.Field);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-2")]
+ [InlineData(1)]
+ [InlineData(250)]
+ [InlineData(20_000)]
+ public void The_usual_quantity_can_be_between_1_and_20000_ml(int millilitres)
+ {
+ Assert.Equal(millilitres, NewRecipient(millilitres: millilitres).ExpectedQuantity.Millilitres);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-2")]
+ [InlineData(0)]
+ [InlineData(20_001)]
+ [InlineData(int.MaxValue)]
+ public void The_usual_quantity_outside_1_to_20000_ml_is_rejected(int millilitres)
+ {
+ var exception = Assert.Throws(() => NewRecipient(millilitres: millilitres));
+
+ Assert.Equal("expectedMl", exception.Field);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-3")]
+ public void The_start_date_is_required()
+ {
+ var exception = Assert.Throws(
+ () => Recipient.Add(Household, "Recipient A", Quantity.FromMillilitres(1000), default, Now));
+
+ Assert.Equal("startsOn", exception.Field);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-4")]
+ [InlineData("2026-09-30", false)]
+ [InlineData("2026-10-01", true)]
+ [InlineData("2026-10-14", true)]
+ [InlineData("2026-10-15", false)]
+ [InlineData("2026-10-16", false)]
+ public void A_recipient_is_active_from_the_start_date_until_the_day_before_archiving(string date, bool expected)
+ {
+ var recipient = NewRecipient();
+ recipient.Archive(new DateOnly(2026, 10, 15), Now);
+
+ Assert.Equal(expected, recipient.IsActiveOn(DateOnly.Parse(date, CultureInfo.InvariantCulture)));
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-4")]
+ [InlineData("2026-09-30", false)]
+ [InlineData("2026-10-01", true)]
+ [InlineData("2035-01-01", true)]
+ public void A_recipient_who_is_not_archived_stays_active_after_the_start_date(string date, bool expected)
+ {
+ Assert.Equal(expected, NewRecipient().IsActiveOn(DateOnly.Parse(date, CultureInfo.InvariantCulture)));
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public void Archive_records_the_first_day_without_deliveries()
+ {
+ var recipient = NewRecipient();
+ var later = Now.AddHours(1);
+
+ recipient.Archive(new DateOnly(2026, 10, 31), later);
+
+ Assert.Equal(new DateOnly(2026, 10, 31), recipient.ArchivedFrom);
+ Assert.Equal(later, recipient.UpdatedAt);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-5")]
+ [InlineData("2026-10-01")]
+ [InlineData("2026-09-01")]
+ public void Archive_needs_a_date_after_the_start_date(string from)
+ {
+ var recipient = NewRecipient();
+
+ var exception = Assert.Throws(
+ () => recipient.Archive(DateOnly.Parse(from, CultureInfo.InvariantCulture), Now));
+
+ Assert.Equal("archivedFrom", exception.Field);
+ Assert.Null(recipient.ArchivedFrom);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public void Archiving_an_archived_recipient_is_rejected()
+ {
+ var recipient = NewRecipient();
+ recipient.Archive(new DateOnly(2026, 10, 15), Now);
+
+ Assert.Throws(() => recipient.Archive(new DateOnly(2026, 10, 20), Now));
+ Assert.Equal(new DateOnly(2026, 10, 15), recipient.ArchivedFrom);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public void Unarchive_clears_the_archive_date()
+ {
+ var recipient = NewRecipient();
+ recipient.Archive(new DateOnly(2026, 10, 15), Now);
+ var later = Now.AddHours(1);
+
+ recipient.Unarchive(later);
+
+ Assert.Null(recipient.ArchivedFrom);
+ Assert.Equal(later, recipient.UpdatedAt);
+ Assert.True(recipient.IsActiveOn(new DateOnly(2026, 10, 20)));
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public void Unarchive_does_nothing_when_the_recipient_is_not_archived()
+ {
+ var recipient = NewRecipient();
+
+ recipient.Unarchive(Now.AddHours(1));
+
+ Assert.Null(recipient.ArchivedFrom);
+ Assert.Equal(Now, recipient.UpdatedAt);
+ }
+
+ [Fact]
+ public void An_archive_date_in_the_future_still_counts_as_active_today()
+ {
+ var recipient = NewRecipient();
+ recipient.Archive(new DateOnly(2026, 12, 1), Now);
+
+ Assert.False(recipient.IsArchivedOn(new DateOnly(2026, 11, 30)));
+ Assert.True(recipient.IsArchivedOn(new DateOnly(2026, 12, 1)));
+ Assert.True(recipient.IsArchivedOn(new DateOnly(2027, 1, 1)));
+ Assert.False(NewRecipient().IsArchivedOn(new DateOnly(2027, 1, 1)));
+ }
+
+ [Fact]
+ public void Update_changes_the_editable_fields()
+ {
+ var recipient = NewRecipient();
+ var later = Now.AddHours(1);
+
+ recipient.Update("Recipient B", Quantity.FromMillilitres(500), new DateOnly(2026, 9, 1), later);
+
+ Assert.Equal("Recipient B", recipient.Name);
+ Assert.Equal(500, recipient.ExpectedQuantity.Millilitres);
+ Assert.Equal(new DateOnly(2026, 9, 1), recipient.StartsOn);
+ Assert.Equal(later, recipient.UpdatedAt);
+ }
+
+ [Fact]
+ public void A_rejected_update_leaves_the_recipient_unchanged()
+ {
+ var recipient = NewRecipient();
+
+ Assert.Throws(
+ () => recipient.Update("Recipient B", Quantity.FromMillilitres(0), Start, Now.AddHours(1)));
+
+ Assert.Equal("Recipient A", recipient.Name);
+ Assert.Equal(1000, recipient.ExpectedQuantity.Millilitres);
+ Assert.Equal(Now, recipient.UpdatedAt);
+ }
+
+ [Fact]
+ public void The_start_date_cannot_be_moved_to_or_after_the_archive_date()
+ {
+ var recipient = NewRecipient();
+ recipient.Archive(new DateOnly(2026, 10, 15), Now);
+
+ var exception = Assert.Throws(
+ () => recipient.Update("Recipient A", Quantity.FromMillilitres(1000), new DateOnly(2026, 10, 15), Now));
+
+ Assert.Equal("startsOn", exception.Field);
+ Assert.Equal(Start, recipient.StartsOn);
+ }
+}
From b2731e02c1cbcc328161fa047bbf592667c598ea Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Fri, 30 Oct 2026 17:48:49 +0530
Subject: [PATCH 02/10] feat(application): add household, recipient and milk
rate use cases
Add handlers for setting up the household, listing, adding, updating,
archiving and restoring recipients, and listing, adding, changing, deleting
and looking up milk rates. Repository interfaces, the unit of work and the
current household are declared here without any EF Core types. Conflicts and
missing data are reported with stable error codes. Handler tests run against
an in-memory database that only commits on save.
---
backend/PureFreshMilk.slnx | 2 +
.../ApplicationServiceCollectionExtensions.cs | 19 +
.../Common/ConflictException.cs | 8 +
.../Common/ErrorCodes.cs | 16 +
.../Common/NotFoundException.cs | 8 +
.../Households/GetHouseholdHandler.cs | 12 +
.../Households/HouseholdDetails.cs | 10 +
.../Households/ICurrentHousehold.cs | 25 ++
.../Households/IHouseholdRepository.cs | 9 +
.../Households/SetUpHouseholdHandler.cs | 44 ++
.../Persistence/IUnitOfWork.cs | 10 +
.../Rates/AddMilkRateHandler.cs | 34 ++
.../Rates/DeleteMilkRateHandler.cs | 21 +
.../Rates/GetRateInEffectHandler.cs | 22 +
.../Rates/IMilkRateRepository.cs | 24 ++
.../Rates/ListMilkRatesHandler.cs | 16 +
.../Rates/MilkRateDetails.cs | 11 +
.../Rates/MilkRateRules.cs | 35 ++
.../Rates/UpdateMilkRateHandler.cs | 33 ++
.../Recipients/AddRecipientHandler.cs | 37 ++
.../Recipients/ArchiveRecipientHandler.cs | 38 ++
.../Recipients/GetRecipientHandler.cs | 13 +
.../Recipients/IRecipientRepository.cs | 26 ++
.../Recipients/ListRecipientsHandler.cs | 19 +
.../Recipients/RecipientDetails.cs | 17 +
.../Recipients/RecipientRules.cs | 48 +++
.../Recipients/UnarchiveRecipientHandler.cs | 30 ++
.../Recipients/UpdateRecipientHandler.cs | 33 ++
.../Recipients/Recipient.cs | 8 +-
.../Households/HouseholdHandlerTests.cs | 105 +++++
.../PureFreshMilk.Application.Tests.csproj | 9 +
.../Rates/MilkRateHandlerTests.cs | 249 +++++++++++
.../Recipients/RecipientHandlerTests.cs | 386 ++++++++++++++++++
.../TestHousehold.cs | 90 ++++
.../FixedTimeProvider.cs | 13 +
.../InMemoryDatabase.cs | 43 ++
.../InMemoryHouseholdRepository.cs | 13 +
.../InMemoryMilkRateRepository.cs | 27 ++
.../InMemoryPersistenceExtensions.cs | 36 ++
.../InMemoryRecipientRepository.cs | 37 ++
.../PureFreshMilk.TestSupport.csproj | 23 ++
41 files changed, 1658 insertions(+), 1 deletion(-)
create mode 100644 backend/src/PureFreshMilk.Application/Common/ConflictException.cs
create mode 100644 backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs
create mode 100644 backend/src/PureFreshMilk.Application/Common/NotFoundException.cs
create mode 100644 backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs
create mode 100644 backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs
create mode 100644 backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs
create mode 100644 backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs
create mode 100644 backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs
create mode 100644 backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs
create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs
create mode 100644 backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj
create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs
create mode 100644 backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs
create mode 100644 backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs
create mode 100644 backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj
diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx
index 696ed1a..135e71a 100644
--- a/backend/PureFreshMilk.slnx
+++ b/backend/PureFreshMilk.slnx
@@ -7,7 +7,9 @@
+
+
diff --git a/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs
index 6dc2f6f..1f4c840 100644
--- a/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/src/PureFreshMilk.Application/ApplicationServiceCollectionExtensions.cs
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Application.Recipients;
using PureFreshMilk.Application.Status;
namespace PureFreshMilk.Application;
@@ -16,6 +19,22 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
return services;
}
}
diff --git a/backend/src/PureFreshMilk.Application/Common/ConflictException.cs b/backend/src/PureFreshMilk.Application/Common/ConflictException.cs
new file mode 100644
index 0000000..646676d
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Common/ConflictException.cs
@@ -0,0 +1,8 @@
+namespace PureFreshMilk.Application.Common;
+
+/// Raised when a request clashes with data that is already stored. The Api answers 409.
+public sealed class ConflictException(string code, string message) : Exception(message)
+{
+ /// One of .
+ public string Code { get; } = code;
+}
diff --git a/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs b/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs
new file mode 100644
index 0000000..6b8c736
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Common/ErrorCodes.cs
@@ -0,0 +1,16 @@
+namespace PureFreshMilk.Application.Common;
+
+///
+/// Stable, machine-readable codes carried by and
+/// . Clients branch on these, so they do not change once released.
+///
+public static class ErrorCodes
+{
+ public const string HouseholdNotSetUp = "household-not-set-up";
+ public const string RecipientNotFound = "recipient-not-found";
+ public const string RecipientNameTaken = "recipient-name-taken";
+ public const string RecipientAlreadyArchived = "recipient-already-archived";
+ public const string RateNotFound = "rate-not-found";
+ public const string RateDateTaken = "rate-date-taken";
+ public const string NoRateInEffect = "no-rate-in-effect";
+}
diff --git a/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs b/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs
new file mode 100644
index 0000000..43809ec
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Common/NotFoundException.cs
@@ -0,0 +1,8 @@
+namespace PureFreshMilk.Application.Common;
+
+/// Raised when something the caller asked for does not exist. The Api answers 404.
+public sealed class NotFoundException(string code, string message) : Exception(message)
+{
+ /// One of .
+ public string Code { get; } = code;
+}
diff --git a/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs b/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs
new file mode 100644
index 0000000..1ca644d
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Households/GetHouseholdHandler.cs
@@ -0,0 +1,12 @@
+namespace PureFreshMilk.Application.Households;
+
+/// Reads the household, including today's date in its time zone.
+public sealed class GetHouseholdHandler(ICurrentHousehold currentHousehold, TimeProvider timeProvider)
+{
+ public async Task HandleAsync(CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+
+ return HouseholdDetails.From(household, timeProvider);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs b/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs
new file mode 100644
index 0000000..e87114f
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Households/HouseholdDetails.cs
@@ -0,0 +1,10 @@
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Application.Households;
+
+/// The calendar date it is now in the household's time zone, decided by the server.
+public sealed record HouseholdDetails(Guid Id, string Name, string TimeZoneId, DateOnly Today)
+{
+ public static HouseholdDetails From(Household household, TimeProvider timeProvider) =>
+ new(household.Id.Value, household.Name, household.TimeZoneId, household.Today(timeProvider.GetUtcNow()));
+}
diff --git a/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs b/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs
new file mode 100644
index 0000000..f5b7152
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Households/ICurrentHousehold.cs
@@ -0,0 +1,25 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Application.Households;
+
+///
+/// The household the caller is working in. Until authentication exists, Infrastructure answers with the
+/// only household (single-household mode); later it will come from the signed-in user, and no use case
+/// changes (ADR 0004).
+///
+public interface ICurrentHousehold
+{
+ /// The household, or null if none has been set up yet.
+ Task FindAsync(CancellationToken cancellationToken);
+}
+
+public static class CurrentHouseholdExtensions
+{
+ /// The household, or if none has been set up yet.
+ public static async Task RequireAsync(
+ this ICurrentHousehold currentHousehold, CancellationToken cancellationToken) =>
+ await currentHousehold.FindAsync(cancellationToken)
+ ?? throw new NotFoundException(
+ ErrorCodes.HouseholdNotSetUp, "The household has not been set up yet.");
+}
diff --git a/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs b/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs
new file mode 100644
index 0000000..1262d79
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Households/IHouseholdRepository.cs
@@ -0,0 +1,9 @@
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Application.Households;
+
+/// Stores households. Implemented (internally) by Infrastructure; saved through IUnitOfWork.
+public interface IHouseholdRepository
+{
+ void Add(Household household);
+}
diff --git a/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs b/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs
new file mode 100644
index 0000000..80532c1
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Households/SetUpHouseholdHandler.cs
@@ -0,0 +1,44 @@
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Application.Households;
+
+/// An IANA id. Left out, it defaults to Asia/Kolkata on creation and is kept on update.
+public sealed record SetUpHouseholdCommand(string? Name, string? TimeZoneId);
+
+/// true if this call created the household, false if it updated the existing one.
+public sealed record SetUpHouseholdResult(HouseholdDetails Household, bool Created);
+
+///
+/// Creates the household the first time and updates it afterwards. In single-household mode there is
+/// never a second household.
+///
+public sealed class SetUpHouseholdHandler(
+ ICurrentHousehold currentHousehold,
+ IHouseholdRepository households,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(
+ SetUpHouseholdCommand command, CancellationToken cancellationToken)
+ {
+ var now = timeProvider.GetUtcNow();
+ var existing = await currentHousehold.FindAsync(cancellationToken);
+
+ Household household;
+ if (existing is null)
+ {
+ household = Household.Create(command.Name, command.TimeZoneId ?? Household.DefaultTimeZoneId, now);
+ households.Add(household);
+ }
+ else
+ {
+ household = existing;
+ household.Update(command.Name, command.TimeZoneId ?? household.TimeZoneId, now);
+ }
+
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return new SetUpHouseholdResult(HouseholdDetails.From(household, timeProvider), Created: existing is null);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs b/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs
new file mode 100644
index 0000000..1ec43dd
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Persistence/IUnitOfWork.cs
@@ -0,0 +1,10 @@
+namespace PureFreshMilk.Application.Persistence;
+
+///
+/// Saves everything a use case changed, in one transaction. Implemented by Infrastructure, which also
+/// turns a clash with a unique index (two requests racing) into a ConflictException.
+///
+public interface IUnitOfWork
+{
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs
new file mode 100644
index 0000000..88ec872
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/AddMilkRateHandler.cs
@@ -0,0 +1,34 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// The first day the price applies. It may be in the past, to back-date a rise entered late.
+/// The price of one litre, in whole paise.
+public sealed record AddMilkRateCommand(DateOnly EffectiveFrom, long PricePerLitrePaise);
+
+public sealed class AddMilkRateHandler(
+ ICurrentHousehold currentHousehold,
+ IMilkRateRepository rates,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(AddMilkRateCommand command, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+
+ var rate = MilkRate.Create(
+ household.Id,
+ command.EffectiveFrom,
+ PricePerLitre.FromPaise(command.PricePerLitrePaise),
+ timeProvider.GetUtcNow());
+
+ await MilkRateRules.EnsureDateIsFreeAsync(rates, household.Id, rate.EffectiveFrom, exceptRate: null, cancellationToken);
+
+ rates.Add(rate);
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return MilkRateDetails.From(rate);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs
new file mode 100644
index 0000000..c50283c
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/DeleteMilkRateHandler.cs
@@ -0,0 +1,21 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+
+namespace PureFreshMilk.Application.Rates;
+
+///
+/// Removes a rate. Once billing exists, a rate that governs a finalised month will be refused here
+/// (period-finalised).
+///
+public sealed class DeleteMilkRateHandler(
+ ICurrentHousehold currentHousehold, IMilkRateRepository rates, IUnitOfWork unitOfWork)
+{
+ public async Task HandleAsync(Guid id, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var rate = await MilkRateRules.RequireAsync(rates, household.Id, id, cancellationToken);
+
+ rates.Remove(rate);
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs b/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs
new file mode 100644
index 0000000..d3cf9bc
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/GetRateInEffectHandler.cs
@@ -0,0 +1,22 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// The price in force on a date. A day before the first rate has no price, which is not the same as free.
+public sealed class GetRateInEffectHandler(
+ ICurrentHousehold currentHousehold, IMilkRateRepository rates, TimeProvider timeProvider)
+{
+ /// The date to price. Left out, it is today in the household's time zone.
+ public async Task HandleAsync(DateOnly? on, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var schedule = new RateSchedule(await rates.ListAsync(household.Id, cancellationToken));
+
+ var rate = schedule.RateOn(on ?? household.Today(timeProvider.GetUtcNow()))
+ ?? throw new NotFoundException(ErrorCodes.NoRateInEffect, "No price is in force on that date.");
+
+ return MilkRateDetails.From(rate);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs b/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs
new file mode 100644
index 0000000..a9d7e6a
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/IMilkRateRepository.cs
@@ -0,0 +1,24 @@
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// Stores milk rates. Implemented (internally) by Infrastructure; saved through IUnitOfWork.
+public interface IMilkRateRepository
+{
+ /// Every rate of the household, in no particular order.
+ Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken);
+
+ Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken);
+
+ ///
+ /// Whether the household already has a rate starting on . The database enforces
+ /// the same rule with a unique index; this check gives a friendly answer first.
+ ///
+ Task EffectiveFromTakenAsync(
+ HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken);
+
+ void Add(MilkRate rate);
+
+ void Remove(MilkRate rate);
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs b/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs
new file mode 100644
index 0000000..86a989a
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/ListMilkRatesHandler.cs
@@ -0,0 +1,16 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// Lists the household's rates, newest effective date first.
+public sealed class ListMilkRatesHandler(ICurrentHousehold currentHousehold, IMilkRateRepository rates)
+{
+ public async Task> HandleAsync(CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var schedule = new RateSchedule(await rates.ListAsync(household.Id, cancellationToken));
+
+ return [.. schedule.Rates.Reverse().Select(MilkRateDetails.From)];
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs b/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs
new file mode 100644
index 0000000..d452dd9
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/MilkRateDetails.cs
@@ -0,0 +1,11 @@
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// The first day this price applies.
+/// The price of one litre, in whole paise.
+public sealed record MilkRateDetails(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise)
+{
+ public static MilkRateDetails From(MilkRate rate) =>
+ new(rate.Id.Value, rate.EffectiveFrom, rate.Price.PaisePerLitre);
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs b/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs
new file mode 100644
index 0000000..f745cae
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/MilkRateRules.cs
@@ -0,0 +1,35 @@
+using System.Globalization;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+/// Checks shared by the milk rate use cases.
+internal static class MilkRateRules
+{
+ public static async Task EnsureDateIsFreeAsync(
+ IMilkRateRepository rates,
+ HouseholdId householdId,
+ DateOnly effectiveFrom,
+ MilkRateId? exceptRate,
+ CancellationToken cancellationToken)
+ {
+ if (await rates.EffectiveFromTakenAsync(householdId, effectiveFrom, exceptRate, cancellationToken))
+ {
+ throw new ConflictException(
+ ErrorCodes.RateDateTaken,
+ $"A price already starts on {effectiveFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}. Change that one instead.");
+ }
+ }
+
+ public static async Task RequireAsync(
+ IMilkRateRepository rates, HouseholdId householdId, Guid id, CancellationToken cancellationToken)
+ {
+ var rate = id == Guid.Empty
+ ? null
+ : await rates.FindAsync(householdId, MilkRateId.From(id), cancellationToken);
+
+ return rate ?? throw new NotFoundException(ErrorCodes.RateNotFound, "There is no such price.");
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs b/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs
new file mode 100644
index 0000000..d1ea88f
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Rates/UpdateMilkRateHandler.cs
@@ -0,0 +1,33 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Application.Rates;
+
+///
+/// Changes the date or price of a rate. Once billing exists, a rate that governs a finalised month will be
+/// refused here (period-finalised).
+///
+public sealed record UpdateMilkRateCommand(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise);
+
+public sealed class UpdateMilkRateHandler(
+ ICurrentHousehold currentHousehold,
+ IMilkRateRepository rates,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(UpdateMilkRateCommand command, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var rate = await MilkRateRules.RequireAsync(rates, household.Id, command.Id, cancellationToken);
+
+ // Check the price and look for a clash before changing anything, so a rejected request leaves the rate as it was.
+ var price = PricePerLitre.FromPaise(command.PricePerLitrePaise);
+ await MilkRateRules.EnsureDateIsFreeAsync(rates, household.Id, command.EffectiveFrom, rate.Id, cancellationToken);
+
+ rate.Change(command.EffectiveFrom, price, timeProvider.GetUtcNow());
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return MilkRateDetails.From(rate);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs
new file mode 100644
index 0000000..465050b
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/AddRecipientHandler.cs
@@ -0,0 +1,37 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// The usual daily quantity, in whole millilitres.
+/// The first day a delivery is expected. Left out, it is today in the household's time zone.
+public sealed record AddRecipientCommand(string? Name, int ExpectedMl, DateOnly? StartsOn);
+
+public sealed class AddRecipientHandler(
+ ICurrentHousehold currentHousehold,
+ IRecipientRepository recipients,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(AddRecipientCommand command, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var now = timeProvider.GetUtcNow();
+
+ var recipient = Recipient.Add(
+ household.Id,
+ command.Name,
+ RecipientRules.ToQuantity(command.ExpectedMl),
+ command.StartsOn ?? household.Today(now),
+ now);
+
+ await RecipientRules.EnsureNameIsFreeAsync(
+ recipients, household.Id, recipient.Name, exceptRecipient: null, cancellationToken);
+
+ recipients.Add(recipient);
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return RecipientDetails.From(recipient);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs
new file mode 100644
index 0000000..c888bce
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+
+namespace PureFreshMilk.Application.Recipients;
+
+///
+/// The first day without deliveries. Left out, it is today in the household's time zone.
+///
+public sealed record ArchiveRecipientCommand(Guid Id, DateOnly? ArchivedFrom);
+
+/// Stops deliveries to a recipient from a date. Their history stays; nothing is deleted.
+public sealed class ArchiveRecipientHandler(
+ ICurrentHousehold currentHousehold,
+ IRecipientRepository recipients,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(ArchiveRecipientCommand command, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var recipient = await RecipientRules.RequireAsync(recipients, household.Id, command.Id, cancellationToken);
+ var now = timeProvider.GetUtcNow();
+
+ if (recipient.ArchivedFrom is { } archivedFrom)
+ {
+ throw new ConflictException(
+ ErrorCodes.RecipientAlreadyArchived,
+ $"The recipient is already archived from {archivedFrom.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}. Bring them back before archiving again.");
+ }
+
+ recipient.Archive(command.ArchivedFrom ?? household.Today(now), now);
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return RecipientDetails.From(recipient);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs
new file mode 100644
index 0000000..d266ec5
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/GetRecipientHandler.cs
@@ -0,0 +1,13 @@
+using PureFreshMilk.Application.Households;
+
+namespace PureFreshMilk.Application.Recipients;
+
+public sealed class GetRecipientHandler(ICurrentHousehold currentHousehold, IRecipientRepository recipients)
+{
+ public async Task HandleAsync(Guid id, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+
+ return RecipientDetails.From(await RecipientRules.RequireAsync(recipients, household.Id, id, cancellationToken));
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs b/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs
new file mode 100644
index 0000000..a491f99
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/IRecipientRepository.cs
@@ -0,0 +1,26 @@
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// Stores recipients. Implemented (internally) by Infrastructure; saved through IUnitOfWork.
+public interface IRecipientRepository
+{
+ ///
+ /// The household's recipients, ordered by name. Unless is set, a
+ /// recipient already archived as of is left out.
+ ///
+ Task> ListAsync(
+ HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken);
+
+ Task FindAsync(HouseholdId householdId, RecipientId id, CancellationToken cancellationToken);
+
+ ///
+ /// Whether a recipient who is not archived already has this name, ignoring case. The database enforces the
+ /// same rule with a partial unique index; this check gives a friendly answer first.
+ ///
+ Task NameInUseByActiveRecipientAsync(
+ HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken);
+
+ void Add(Recipient recipient);
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs
new file mode 100644
index 0000000..a65d476
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/ListRecipientsHandler.cs
@@ -0,0 +1,19 @@
+using PureFreshMilk.Application.Households;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// Lists the household's recipients by name. Archived recipients are left out unless asked for.
+public sealed class ListRecipientsHandler(
+ ICurrentHousehold currentHousehold, IRecipientRepository recipients, TimeProvider timeProvider)
+{
+ public async Task> HandleAsync(
+ bool includeArchived, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var today = household.Today(timeProvider.GetUtcNow());
+
+ var found = await recipients.ListAsync(household.Id, includeArchived, today, cancellationToken);
+
+ return [.. found.Select(RecipientDetails.From)];
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs b/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs
new file mode 100644
index 0000000..caadb09
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/RecipientDetails.cs
@@ -0,0 +1,17 @@
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// The usual daily quantity, in whole millilitres.
+/// The first day a delivery is expected.
+/// The first day without deliveries, or null while the recipient is not archived.
+public sealed record RecipientDetails(Guid Id, string Name, int ExpectedMl, DateOnly StartsOn, DateOnly? ArchivedFrom)
+{
+ public static RecipientDetails From(Recipient recipient) =>
+ new(
+ recipient.Id.Value,
+ recipient.Name,
+ recipient.ExpectedQuantity.Millilitres,
+ recipient.StartsOn,
+ recipient.ArchivedFrom);
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs b/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs
new file mode 100644
index 0000000..d29dcd2
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/RecipientRules.cs
@@ -0,0 +1,48 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// Checks shared by the recipient use cases.
+internal static class RecipientRules
+{
+ /// Turns the millilitres in a request into a , naming the field if it is invalid.
+ public static Quantity ToQuantity(int millilitres)
+ {
+ try
+ {
+ return Quantity.FromMillilitres(millilitres);
+ }
+ catch (DomainException exception) when (exception.Field is null)
+ {
+ throw new DomainException(exception.Message, "expectedMl");
+ }
+ }
+
+ public static async Task EnsureNameIsFreeAsync(
+ IRecipientRepository recipients,
+ HouseholdId householdId,
+ string name,
+ RecipientId? exceptRecipient,
+ CancellationToken cancellationToken)
+ {
+ if (await recipients.NameInUseByActiveRecipientAsync(householdId, name, exceptRecipient, cancellationToken))
+ {
+ throw new ConflictException(
+ ErrorCodes.RecipientNameTaken,
+ $"Another recipient who is not archived is already called '{name}'.");
+ }
+ }
+
+ public static async Task RequireAsync(
+ IRecipientRepository recipients, HouseholdId householdId, Guid id, CancellationToken cancellationToken)
+ {
+ var recipient = id == Guid.Empty
+ ? null
+ : await recipients.FindAsync(householdId, RecipientId.From(id), cancellationToken);
+
+ return recipient ?? throw new NotFoundException(ErrorCodes.RecipientNotFound, "There is no such recipient.");
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs
new file mode 100644
index 0000000..f876e1b
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/UnarchiveRecipientHandler.cs
@@ -0,0 +1,30 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// Brings an archived recipient back. Does nothing, and succeeds, if they are not archived.
+public sealed class UnarchiveRecipientHandler(
+ ICurrentHousehold currentHousehold,
+ IRecipientRepository recipients,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(Guid id, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var recipient = await RecipientRules.RequireAsync(recipients, household.Id, id, cancellationToken);
+
+ if (recipient.ArchivedFrom is not null)
+ {
+ // Someone else may have taken the name while this recipient was archived.
+ await RecipientRules.EnsureNameIsFreeAsync(
+ recipients, household.Id, recipient.Name, recipient.Id, cancellationToken);
+
+ recipient.Unarchive(timeProvider.GetUtcNow());
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+ }
+
+ return RecipientDetails.From(recipient);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs b/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs
new file mode 100644
index 0000000..391aacf
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/UpdateRecipientHandler.cs
@@ -0,0 +1,33 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+/// Replaces the editable fields of a recipient.
+public sealed record UpdateRecipientCommand(Guid Id, string? Name, int ExpectedMl, DateOnly StartsOn);
+
+public sealed class UpdateRecipientHandler(
+ ICurrentHousehold currentHousehold,
+ IRecipientRepository recipients,
+ IUnitOfWork unitOfWork,
+ TimeProvider timeProvider)
+{
+ public async Task HandleAsync(UpdateRecipientCommand command, CancellationToken cancellationToken)
+ {
+ var household = await currentHousehold.RequireAsync(cancellationToken);
+ var recipient = await RecipientRules.RequireAsync(recipients, household.Id, command.Id, cancellationToken);
+
+ // Look for a clash before changing anything, so a rejected request leaves the recipient as it was.
+ var name = Recipient.NormalizeName(command.Name);
+ if (recipient.ArchivedFrom is null)
+ {
+ await RecipientRules.EnsureNameIsFreeAsync(recipients, household.Id, name, recipient.Id, cancellationToken);
+ }
+
+ recipient.Update(name, RecipientRules.ToQuantity(command.ExpectedMl), command.StartsOn, timeProvider.GetUtcNow());
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return RecipientDetails.From(recipient);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
index 8989677..0ed2798 100644
--- a/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
+++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
@@ -53,6 +53,12 @@ public static Recipient Add(
public void Update(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now) =>
Apply(name, expectedQuantity, startsOn, now);
+ ///
+ /// The name as it will be stored (trimmed), or a rule violation if it is not a valid name. Lets a caller
+ /// compare names, for example to look for a duplicate, before changing anything.
+ ///
+ public static string NormalizeName(string? name) => NameRules.Require(name, "name", "The recipient's name");
+
/// Stops deliveries from onwards. The days before it keep their history.
public void Archive(DateOnly from, DateTimeOffset now)
{
@@ -94,7 +100,7 @@ public bool IsActiveOn(DateOnly date) =>
private void Apply(string? name, Quantity expectedQuantity, DateOnly startsOn, DateTimeOffset now)
{
// Check everything before changing anything, so a rejected update leaves the recipient as it was.
- var validName = NameRules.Require(name, "name", "The recipient's name");
+ var validName = NormalizeName(name);
if (expectedQuantity.Millilitres is < MinExpectedMillilitres or > MaxExpectedMillilitres)
{
diff --git a/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs
new file mode 100644
index 0000000..9444485
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Application.Tests/Households/HouseholdHandlerTests.cs
@@ -0,0 +1,105 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Application.Tests.Households;
+
+public sealed class HouseholdHandlerTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Reading_before_the_household_is_set_up_is_not_found()
+ {
+ var app = new TestHousehold();
+
+ var exception = await Assert.ThrowsAsync(() => app.GetHousehold.HandleAsync(Ct));
+
+ Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code);
+ }
+
+ [Fact]
+ public async Task Set_up_creates_the_household_with_the_default_time_zone()
+ {
+ var app = new TestHousehold();
+
+ var result = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand(" Example household ", null), Ct);
+
+ Assert.True(result.Created);
+ Assert.Equal("Example household", result.Household.Name);
+ Assert.Equal("Asia/Kolkata", result.Household.TimeZoneId);
+ Assert.Equal(TestHousehold.Today, result.Household.Today);
+ Assert.Single(app.Database.Households);
+ Assert.Equal(1, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task Set_up_can_choose_the_time_zone()
+ {
+ var app = new TestHousehold();
+
+ var result = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Home", "Europe/London"), Ct);
+
+ Assert.Equal("Europe/London", result.Household.TimeZoneId);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-5")]
+ public async Task Setting_up_again_updates_the_only_household_instead_of_creating_a_second()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var originalId = Assert.Single(app.Database.Households).Id.Value;
+
+ var renamed = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Renamed", null), Ct);
+ var moved = await app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Renamed", "Europe/London"), Ct);
+
+ Assert.False(renamed.Created);
+ Assert.False(moved.Created);
+ Assert.Equal(originalId, Assert.Single(app.Database.Households).Id.Value);
+ Assert.Equal("Renamed", renamed.Household.Name);
+ Assert.Equal("Asia/Kolkata", renamed.Household.TimeZoneId); // an omitted time zone keeps the current one
+ Assert.Equal("Europe/London", moved.Household.TimeZoneId);
+ }
+
+ [Theory]
+ [InlineData(null, "Asia/Kolkata", "name")]
+ [InlineData(" ", "Asia/Kolkata", "name")]
+ [InlineData("Home", "Mars/Olympus_Mons", "timeZoneId")]
+ [InlineData("Home", "India Standard Time", "timeZoneId")]
+ public async Task Invalid_input_is_rejected_and_nothing_is_stored(string? name, string timeZoneId, string field)
+ {
+ var app = new TestHousehold();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand(name, timeZoneId), Ct));
+
+ Assert.Equal(field, exception.Field);
+ Assert.Empty(app.Database.Households);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task An_invalid_update_leaves_the_household_as_it_was()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ await Assert.ThrowsAsync(
+ () => app.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("New name", "Mars/Olympus_Mons"), Ct));
+
+ var household = await app.GetHousehold.HandleAsync(Ct);
+ Assert.Equal("Example household", household.Name);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task The_household_reports_today_in_its_own_time_zone()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 29, 0, TimeSpan.Zero));
+ Assert.Equal(new DateOnly(2026, 10, 30), (await app.GetHousehold.HandleAsync(Ct)).Today);
+
+ app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero));
+ Assert.Equal(new DateOnly(2026, 10, 31), (await app.GetHousehold.HandleAsync(Ct)).Today);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj b/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj
new file mode 100644
index 0000000..8648d19
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Application.Tests/PureFreshMilk.Application.Tests.csproj
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs
new file mode 100644
index 0000000..14dad7b
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Application.Tests/Rates/MilkRateHandlerTests.cs
@@ -0,0 +1,249 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Application.Tests.Rates;
+
+public sealed class MilkRateHandlerTests
+{
+ private static readonly DateOnly October1 = new(2026, 10, 1);
+ private static readonly DateOnly October15 = new(2026, 10, 15);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Adding_stores_the_rate()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var rate = await app.AddRateAsync(October1, 6250);
+
+ Assert.Equal(new MilkRateDetails(rate.Id, October1, 6250), rate);
+ Assert.Equal(rate.Id, Assert.Single(app.Database.MilkRates).Id.Value);
+ Assert.Equal(1, app.Database.SaveCount);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-4")]
+ public async Task A_rate_can_be_added_for_a_day_in_the_past()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var rate = await app.AddRateAsync(new DateOnly(2020, 1, 1), 4000);
+
+ Assert.Equal(new DateOnly(2020, 1, 1), rate.EffectiveFrom);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-3")]
+ public async Task A_second_rate_for_the_same_day_is_a_conflict()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October1, 6250);
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, 6400));
+
+ Assert.Equal(ErrorCodes.RateDateTaken, exception.Code);
+ Assert.Single(app.Database.MilkRates);
+ }
+
+ [Theory]
+ [Trait("Invariant", "T-1")]
+ [InlineData(0)]
+ [InlineData(-100)]
+ [InlineData(1_000_001)]
+ public async Task An_invalid_price_is_rejected_with_the_field_and_nothing_is_stored(long paise)
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, paise));
+
+ Assert.Equal("pricePerLitrePaise", exception.Field);
+ Assert.Empty(app.Database.MilkRates);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-1")]
+ public async Task A_missing_date_is_rejected()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(default, 6250));
+
+ Assert.Equal("effectiveFrom", exception.Field);
+ }
+
+ [Fact]
+ public async Task Adding_before_the_household_is_set_up_is_not_found()
+ {
+ var app = new TestHousehold();
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRateAsync(October1, 6250));
+
+ Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code);
+ }
+
+ [Fact]
+ public async Task The_list_shows_the_newest_effective_date_first()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October15, 6400);
+ await app.AddRateAsync(new DateOnly(2026, 9, 1), 5500); // added last, but back-dated
+ await app.AddRateAsync(October1, 6000);
+
+ var list = await app.ListRates.HandleAsync(Ct);
+
+ Assert.Equal([October15, October1, new DateOnly(2026, 9, 1)], list.Select(rate => rate.EffectiveFrom));
+ }
+
+ [Fact]
+ public async Task The_list_is_empty_before_any_rate_is_added()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ Assert.Empty(await app.ListRates.HandleAsync(Ct));
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-5")]
+ public async Task With_no_rate_in_force_the_answer_is_none_not_zero()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.GetRateInEffect.HandleAsync(null, Ct));
+
+ Assert.Equal(ErrorCodes.NoRateInEffect, exception.Code);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-5")]
+ public async Task A_day_before_the_first_rate_has_no_price()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October1, 6000);
+
+ await Assert.ThrowsAsync(() => app.GetRateInEffect.HandleAsync(new DateOnly(2026, 9, 30), Ct));
+ }
+
+ [Theory]
+ [InlineData("2026-10-01", 6000)]
+ [InlineData("2026-10-14", 6000)]
+ [InlineData("2026-10-15", 6400)]
+ [InlineData("2027-01-01", 6400)]
+ public async Task The_rate_in_force_is_the_latest_one_starting_on_or_before_the_day(string date, long expectedPaise)
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October15, 6400);
+ await app.AddRateAsync(October1, 6000);
+
+ var rate = await app.GetRateInEffect.HandleAsync(
+ DateOnly.Parse(date, System.Globalization.CultureInfo.InvariantCulture), Ct);
+
+ Assert.Equal(expectedPaise, rate.PricePerLitrePaise);
+ }
+
+ [Fact]
+ public async Task The_rate_in_force_defaults_to_today_in_the_household_time_zone()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October1, 6000);
+ await app.AddRateAsync(new DateOnly(2026, 10, 31), 6400); // starts tomorrow, in Asia/Kolkata
+
+ Assert.Equal(6000, (await app.GetRateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise);
+
+ app.Clock.Set(new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero)); // midnight arrives in Asia/Kolkata
+ Assert.Equal(6400, (await app.GetRateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise);
+ }
+
+ [Fact]
+ public async Task Updating_changes_the_date_and_price()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRateAsync(October1, 6000);
+ app.Database.ResetSaveCount();
+
+ var updated = await app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October15, 6400), Ct);
+
+ Assert.Equal(new MilkRateDetails(added.Id, October15, 6400), updated);
+ Assert.Equal(1, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task A_rate_can_be_updated_without_changing_its_date()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRateAsync(October1, 6000);
+
+ var updated = await app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October1, 6100), Ct);
+
+ Assert.Equal(6100, updated.PricePerLitrePaise);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-3")]
+ public async Task Moving_a_rate_onto_the_day_of_another_is_a_conflict_and_changes_nothing()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRateAsync(October1, 6000);
+ var other = await app.AddRateAsync(October15, 6400);
+ app.Database.ResetSaveCount();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(other.Id, October1, 7000), Ct));
+
+ Assert.Equal(ErrorCodes.RateDateTaken, exception.Code);
+ Assert.Equal(October15, app.Database.MilkRates.Single(rate => rate.Id.Value == other.Id).EffectiveFrom);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task An_invalid_update_is_rejected_and_changes_nothing()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRateAsync(October1, 6000);
+ app.Database.ResetSaveCount();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(added.Id, October15, 0), Ct));
+
+ Assert.Equal("pricePerLitrePaise", exception.Field);
+ var stored = Assert.Single(app.Database.MilkRates);
+ Assert.Equal(October1, stored.EffectiveFrom);
+ Assert.Equal(6000, stored.Price.PaisePerLitre);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task Updating_an_unknown_rate_is_not_found()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.UpdateRate.HandleAsync(new UpdateMilkRateCommand(Guid.CreateVersion7(), October1, 6000), Ct));
+
+ Assert.Equal(ErrorCodes.RateNotFound, exception.Code);
+ }
+
+ [Fact]
+ public async Task Deleting_removes_the_rate()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var keep = await app.AddRateAsync(October1, 6000);
+ var remove = await app.AddRateAsync(October15, 6400);
+
+ await app.DeleteRate.HandleAsync(remove.Id, Ct);
+
+ Assert.Equal(keep.Id, Assert.Single(app.Database.MilkRates).Id.Value);
+ }
+
+ [Fact]
+ public async Task Deleting_an_unknown_rate_is_not_found()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.DeleteRate.HandleAsync(Guid.CreateVersion7(), Ct));
+
+ Assert.Equal(ErrorCodes.RateNotFound, exception.Code);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs
new file mode 100644
index 0000000..f246b9e
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Application.Tests/Recipients/RecipientHandlerTests.cs
@@ -0,0 +1,386 @@
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Application.Tests.Recipients;
+
+public sealed class RecipientHandlerTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Adding_stores_the_recipient_and_starts_it_today_by_default()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var recipient = await app.AddRecipientAsync(" Recipient A ", 1000);
+
+ Assert.Equal("Recipient A", recipient.Name);
+ Assert.Equal(1000, recipient.ExpectedMl);
+ Assert.Equal(TestHousehold.Today, recipient.StartsOn);
+ Assert.Null(recipient.ArchivedFrom);
+ Assert.Equal(recipient.Id, Assert.Single(app.Database.Recipients).Id.Value);
+ Assert.Equal(1, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task Adding_can_start_the_recipient_on_another_day()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var recipient = await app.AddRecipientAsync("Recipient A", 500, new DateOnly(2026, 10, 1));
+
+ Assert.Equal(new DateOnly(2026, 10, 1), recipient.StartsOn);
+ }
+
+ [Fact]
+ public async Task Adding_before_the_household_is_set_up_is_not_found()
+ {
+ var app = new TestHousehold();
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRecipientAsync("Recipient A"));
+
+ Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code);
+ }
+
+ [Theory]
+ [InlineData(null, 1000, "name")]
+ [InlineData(" ", 1000, "name")]
+ [InlineData("Recipient A", 0, "expectedMl")]
+ [InlineData("Recipient A", -250, "expectedMl")]
+ [InlineData("Recipient A", 20_001, "expectedMl")]
+ public async Task Invalid_input_is_rejected_with_the_field_and_nothing_is_stored(
+ string? name, int expectedMl, string field)
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.AddRecipient.HandleAsync(new AddRecipientCommand(name, expectedMl, null), Ct));
+
+ Assert.Equal(field, exception.Field);
+ Assert.Empty(app.Database.Recipients);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task A_name_already_used_by_an_active_recipient_is_a_conflict_whatever_its_case()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRecipientAsync("Recipient A");
+
+ var exception = await Assert.ThrowsAsync(() => app.AddRecipientAsync(" recipient a "));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ Assert.Single(app.Database.Recipients);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task The_name_of_an_archived_recipient_can_be_used_again()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var first = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(first.Id, null), Ct);
+
+ var second = await app.AddRecipientAsync("Recipient A");
+
+ Assert.NotEqual(first.Id, second.Id);
+ Assert.Equal(2, app.Database.Recipients.Count);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-9")]
+ public async Task The_list_hides_archived_recipients_unless_asked_for()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRecipientAsync("Recipient B", 500, new DateOnly(2026, 10, 1));
+ var gone = await app.AddRecipientAsync("Recipient C", 500, new DateOnly(2026, 10, 1));
+ await app.AddRecipientAsync("Recipient A", 1000, new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(gone.Id, new DateOnly(2026, 10, 20)), Ct);
+
+ var active = await app.ListRecipients.HandleAsync(includeArchived: false, Ct);
+ var all = await app.ListRecipients.HandleAsync(includeArchived: true, Ct);
+
+ Assert.Equal(["Recipient A", "Recipient B"], active.Select(recipient => recipient.Name));
+ Assert.Equal(["Recipient A", "Recipient B", "Recipient C"], all.Select(recipient => recipient.Name));
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-9")]
+ public async Task A_recipient_archived_from_a_future_day_still_appears_in_the_default_list()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var recipient = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(recipient.Id, new DateOnly(2026, 12, 1)), Ct);
+
+ var active = await app.ListRecipients.HandleAsync(includeArchived: false, Ct);
+
+ Assert.Single(active);
+ }
+
+ [Fact]
+ public async Task The_list_is_ordered_by_name_ignoring_case()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRecipientAsync("charlie");
+ await app.AddRecipientAsync("Alice");
+ await app.AddRecipientAsync("bob");
+
+ var list = await app.ListRecipients.HandleAsync(includeArchived: false, Ct);
+
+ Assert.Equal(["Alice", "bob", "charlie"], list.Select(recipient => recipient.Name));
+ }
+
+ [Fact]
+ public async Task The_list_is_empty_for_a_household_with_no_recipients()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ Assert.Empty(await app.ListRecipients.HandleAsync(includeArchived: true, Ct));
+ }
+
+ [Fact]
+ public async Task Listing_before_the_household_is_set_up_is_not_found()
+ {
+ var app = new TestHousehold();
+
+ await Assert.ThrowsAsync(() => app.ListRecipients.HandleAsync(false, Ct));
+ }
+
+ [Fact]
+ public async Task Getting_a_recipient_returns_it()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A");
+
+ var found = await app.GetRecipient.HandleAsync(added.Id, Ct);
+
+ Assert.Equal(added, found);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Getting_an_unknown_recipient_is_not_found(bool emptyId)
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.GetRecipient.HandleAsync(emptyId ? Guid.Empty : Guid.CreateVersion7(), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code);
+ }
+
+ [Fact]
+ public async Task Updating_replaces_the_editable_fields()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", 1000, new DateOnly(2026, 10, 1));
+ app.Database.ResetSaveCount();
+
+ var updated = await app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(added.Id, " Recipient Z ", 500, new DateOnly(2026, 9, 15)), Ct);
+
+ Assert.Equal(new RecipientDetails(added.Id, "Recipient Z", 500, new DateOnly(2026, 9, 15), null), updated);
+ Assert.Equal(1, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task A_recipient_can_change_the_case_of_its_own_name()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("recipient a");
+
+ var updated = await app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(added.Id, "Recipient A", 1000, added.StartsOn), Ct);
+
+ Assert.Equal("Recipient A", updated.Name);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task Renaming_to_the_name_of_another_active_recipient_is_a_conflict_and_changes_nothing()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRecipientAsync("Recipient A");
+ var other = await app.AddRecipientAsync("Recipient B", 500);
+ app.Database.ResetSaveCount();
+
+ var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(other.Id, "RECIPIENT A", 250, other.StartsOn), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ Assert.Equal(other, await app.GetRecipient.HandleAsync(other.Id, Ct));
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task An_archived_recipient_can_be_renamed_to_a_name_in_use()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ var archived = await app.AddRecipientAsync("Recipient B", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(archived.Id, null), Ct);
+
+ var updated = await app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(archived.Id, "Recipient A", 500, archived.StartsOn), Ct);
+
+ Assert.Equal("Recipient A", updated.Name);
+ }
+
+ [Theory]
+ [InlineData("", 500, "name")]
+ [InlineData("Recipient A", 0, "expectedMl")]
+ public async Task An_invalid_update_is_rejected_and_changes_nothing(string name, int expectedMl, string field)
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", 1000);
+ app.Database.ResetSaveCount();
+
+ var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(added.Id, name, expectedMl, added.StartsOn), Ct));
+
+ Assert.Equal(field, exception.Field);
+ Assert.Equal(added, await app.GetRecipient.HandleAsync(added.Id, Ct));
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ public async Task Updating_an_unknown_recipient_is_not_found()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(Guid.CreateVersion7(), "Recipient A", 500, TestHousehold.Today), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code);
+ }
+
+ [Fact]
+ public async Task Archiving_defaults_to_today()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+
+ var archived = await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct);
+
+ Assert.Equal(TestHousehold.Today, archived.ArchivedFrom);
+ Assert.Equal(TestHousehold.Today, Assert.Single(app.Database.Recipients).ArchivedFrom);
+ }
+
+ [Fact]
+ public async Task Archiving_can_name_the_day()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+
+ var archived = await app.ArchiveRecipient.HandleAsync(
+ new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 11, 15)), Ct);
+
+ Assert.Equal(new DateOnly(2026, 11, 15), archived.ArchivedFrom);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public async Task Archiving_on_or_before_the_start_date_is_rejected()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ app.Database.ResetSaveCount();
+
+ var exception = await Assert.ThrowsAsync(() => app.ArchiveRecipient.HandleAsync(
+ new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 10, 1)), Ct));
+
+ Assert.Equal("archivedFrom", exception.Field);
+ Assert.Null(Assert.Single(app.Database.Recipients).ArchivedFrom);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-5")]
+ public async Task Archiving_an_archived_recipient_is_a_conflict()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct);
+
+ var exception = await Assert.ThrowsAsync(
+ () => app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, new DateOnly(2026, 11, 1)), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientAlreadyArchived, exception.Code);
+ Assert.Equal(TestHousehold.Today, Assert.Single(app.Database.Recipients).ArchivedFrom);
+ }
+
+ [Fact]
+ public async Task Archiving_an_unknown_recipient_is_not_found()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ var exception = await Assert.ThrowsAsync(() => app.ArchiveRecipient.HandleAsync(
+ new ArchiveRecipientCommand(Guid.CreateVersion7(), null), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNotFound, exception.Code);
+ }
+
+ [Fact]
+ public async Task Archiving_never_deletes_the_recipient()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct);
+
+ Assert.Single(app.Database.Recipients);
+ Assert.Equal(added.Id, (await app.GetRecipient.HandleAsync(added.Id, Ct)).Id);
+ }
+
+ [Fact]
+ public async Task Unarchiving_brings_the_recipient_back()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct);
+
+ var restored = await app.UnarchiveRecipient.HandleAsync(added.Id, Ct);
+
+ Assert.Null(restored.ArchivedFrom);
+ Assert.Single(await app.ListRecipients.HandleAsync(includeArchived: false, Ct));
+ }
+
+ [Fact]
+ public async Task Unarchiving_a_recipient_who_is_not_archived_does_nothing()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var added = await app.AddRecipientAsync("Recipient A");
+ app.Database.ResetSaveCount();
+
+ var result = await app.UnarchiveRecipient.HandleAsync(added.Id, Ct);
+
+ Assert.Equal(added, result);
+ Assert.Equal(0, app.Database.SaveCount);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task Unarchiving_into_a_name_that_is_now_taken_is_a_conflict()
+ {
+ var app = await TestHousehold.SetUpAsync();
+ var first = await app.AddRecipientAsync("Recipient A", startsOn: new DateOnly(2026, 10, 1));
+ await app.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(first.Id, null), Ct);
+ await app.AddRecipientAsync("Recipient A");
+
+ var exception = await Assert.ThrowsAsync(() => app.UnarchiveRecipient.HandleAsync(first.Id, Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ Assert.Equal(TestHousehold.Today, app.Database.Recipients.Single(recipient => recipient.Id.Value == first.Id).ArchivedFrom);
+ }
+
+ [Fact]
+ public async Task Unarchiving_an_unknown_recipient_is_not_found()
+ {
+ var app = await TestHousehold.SetUpAsync();
+
+ await Assert.ThrowsAsync(() => app.UnarchiveRecipient.HandleAsync(Guid.CreateVersion7(), Ct));
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs b/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs
new file mode 100644
index 0000000..a3a807a
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Application.Tests/TestHousehold.cs
@@ -0,0 +1,90 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.TestSupport;
+
+namespace PureFreshMilk.Application.Tests;
+
+///
+/// A household's worth of handlers wired to in-memory persistence. The clock stands at 06:00 UTC on
+/// 30 October 2026, which is 11:30 on the same day in Asia/Kolkata.
+///
+internal sealed class TestHousehold
+{
+ public static readonly DateTimeOffset Now = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+ public static readonly DateOnly Today = new(2026, 10, 30);
+
+ public TestHousehold()
+ {
+ Database = new InMemoryDatabase();
+ Clock = new FixedTimeProvider(Now);
+
+ var households = new InMemoryHouseholdRepository(Database);
+ var recipients = new InMemoryRecipientRepository(Database);
+ var rates = new InMemoryMilkRateRepository(Database);
+
+ GetHousehold = new GetHouseholdHandler(households, Clock);
+ SetUpHousehold = new SetUpHouseholdHandler(households, households, Database, Clock);
+
+ ListRecipients = new ListRecipientsHandler(households, recipients, Clock);
+ GetRecipient = new GetRecipientHandler(households, recipients);
+ AddRecipient = new AddRecipientHandler(households, recipients, Database, Clock);
+ UpdateRecipient = new UpdateRecipientHandler(households, recipients, Database, Clock);
+ ArchiveRecipient = new ArchiveRecipientHandler(households, recipients, Database, Clock);
+ UnarchiveRecipient = new UnarchiveRecipientHandler(households, recipients, Database, Clock);
+
+ ListRates = new ListMilkRatesHandler(households, rates);
+ GetRateInEffect = new GetRateInEffectHandler(households, rates, Clock);
+ AddRate = new AddMilkRateHandler(households, rates, Database, Clock);
+ UpdateRate = new UpdateMilkRateHandler(households, rates, Database, Clock);
+ DeleteRate = new DeleteMilkRateHandler(households, rates, Database);
+ }
+
+ public InMemoryDatabase Database { get; }
+
+ public FixedTimeProvider Clock { get; }
+
+ public GetHouseholdHandler GetHousehold { get; }
+
+ public SetUpHouseholdHandler SetUpHousehold { get; }
+
+ public ListRecipientsHandler ListRecipients { get; }
+
+ public GetRecipientHandler GetRecipient { get; }
+
+ public AddRecipientHandler AddRecipient { get; }
+
+ public UpdateRecipientHandler UpdateRecipient { get; }
+
+ public ArchiveRecipientHandler ArchiveRecipient { get; }
+
+ public UnarchiveRecipientHandler UnarchiveRecipient { get; }
+
+ public ListMilkRatesHandler ListRates { get; }
+
+ public GetRateInEffectHandler GetRateInEffect { get; }
+
+ public AddMilkRateHandler AddRate { get; }
+
+ public UpdateMilkRateHandler UpdateRate { get; }
+
+ public DeleteMilkRateHandler DeleteRate { get; }
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ /// A household that has been set up, as the first screen of the app does.
+ public static async Task SetUpAsync(string timeZoneId = "Asia/Kolkata")
+ {
+ var household = new TestHousehold();
+ await household.SetUpHousehold.HandleAsync(new SetUpHouseholdCommand("Example household", timeZoneId), Ct);
+ household.Database.ResetSaveCount();
+ return household;
+ }
+
+ public async Task AddRecipientAsync(
+ string name, int expectedMl = 1000, DateOnly? startsOn = null) =>
+ await AddRecipient.HandleAsync(new AddRecipientCommand(name, expectedMl, startsOn), Ct);
+
+ public async Task AddRateAsync(DateOnly effectiveFrom, long pricePerLitrePaise) =>
+ await AddRate.HandleAsync(new AddMilkRateCommand(effectiveFrom, pricePerLitrePaise), Ct);
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs b/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs
new file mode 100644
index 0000000..44137c3
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/FixedTimeProvider.cs
@@ -0,0 +1,13 @@
+namespace PureFreshMilk.TestSupport;
+
+/// A clock that stands still until a test moves it.
+public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
+{
+ private DateTimeOffset utcNow = utcNow;
+
+ public override DateTimeOffset GetUtcNow() => utcNow;
+
+ public void Set(DateTimeOffset value) => utcNow = value;
+
+ public void Advance(TimeSpan by) => utcNow += by;
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs
new file mode 100644
index 0000000..bab5b40
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryDatabase.cs
@@ -0,0 +1,43 @@
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.TestSupport;
+
+///
+/// A stand-in for the database. Like a real unit of work, it keeps what a use case adds or removes
+/// to itself until is called, so a handler that forgets to save fails its test.
+/// It hands out the stored entity instances, so a handler must check a request before changing an
+/// entity (as the real handlers do): nothing here can roll a change back.
+///
+public sealed class InMemoryDatabase : IUnitOfWork
+{
+ private readonly List pendingChanges = [];
+
+ public List Households { get; } = [];
+
+ public List Recipients { get; } = [];
+
+ public List MilkRates { get; } = [];
+
+ /// How many times has been called.
+ public int SaveCount { get; private set; }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken)
+ {
+ foreach (var change in pendingChanges)
+ {
+ change();
+ }
+
+ pendingChanges.Clear();
+ SaveCount++;
+ return Task.CompletedTask;
+ }
+
+ /// Forgets earlier saves, so a test can check that the step under test saved (or did not save).
+ public void ResetSaveCount() => SaveCount = 0;
+
+ internal void StageChange(Action change) => pendingChanges.Add(change);
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs
new file mode 100644
index 0000000..2daea92
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryHouseholdRepository.cs
@@ -0,0 +1,13 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.TestSupport;
+
+/// Stores households, and answers as the current household the way single-household mode does: the only one.
+public sealed class InMemoryHouseholdRepository(InMemoryDatabase database) : IHouseholdRepository, ICurrentHousehold
+{
+ public void Add(Household household) => database.StageChange(() => database.Households.Add(household));
+
+ public Task FindAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(database.Households.SingleOrDefault());
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs
new file mode 100644
index 0000000..3190913
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryMilkRateRepository.cs
@@ -0,0 +1,27 @@
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.TestSupport;
+
+public sealed class InMemoryMilkRateRepository(InMemoryDatabase database) : IMilkRateRepository
+{
+ public Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken)
+ {
+ IReadOnlyList found = [.. database.MilkRates.Where(rate => rate.HouseholdId == householdId)];
+
+ return Task.FromResult(found);
+ }
+
+ public Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken) =>
+ Task.FromResult(database.MilkRates.SingleOrDefault(rate => rate.HouseholdId == householdId && rate.Id == id));
+
+ public Task EffectiveFromTakenAsync(
+ HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken) =>
+ Task.FromResult(database.MilkRates.Any(rate =>
+ rate.HouseholdId == householdId && rate.EffectiveFrom == effectiveFrom && rate.Id != exceptRate));
+
+ public void Add(MilkRate rate) => database.StageChange(() => database.MilkRates.Add(rate));
+
+ public void Remove(MilkRate rate) => database.StageChange(() => database.MilkRates.Remove(rate));
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs
new file mode 100644
index 0000000..31ad179
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryPersistenceExtensions.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Application.Recipients;
+
+namespace PureFreshMilk.TestSupport;
+
+public static class InMemoryPersistenceExtensions
+{
+ ///
+ /// Replaces the real repositories, unit of work and current household with in-memory ones, so the
+ /// HTTP pipeline can be tested without a database. Returns the database so a test can inspect it.
+ ///
+ public static InMemoryDatabase UseInMemoryPersistence(this IServiceCollection services)
+ {
+ var database = new InMemoryDatabase();
+ var households = new InMemoryHouseholdRepository(database);
+
+ services.RemoveAll();
+ services.RemoveAll();
+ services.RemoveAll();
+ services.RemoveAll();
+ services.RemoveAll();
+
+ services.AddSingleton(database);
+ services.AddSingleton(database);
+ services.AddSingleton(households);
+ services.AddSingleton(households);
+ services.AddSingleton(new InMemoryRecipientRepository(database));
+ services.AddSingleton(new InMemoryMilkRateRepository(database));
+
+ return database;
+ }
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs b/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs
new file mode 100644
index 0000000..e961163
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/InMemoryRecipientRepository.cs
@@ -0,0 +1,37 @@
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.TestSupport;
+
+public sealed class InMemoryRecipientRepository(InMemoryDatabase database) : IRecipientRepository
+{
+ public Task> ListAsync(
+ HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken)
+ {
+ IReadOnlyList found =
+ [
+ .. database.Recipients
+ .Where(recipient => recipient.HouseholdId == householdId)
+ .Where(recipient => includeArchived || !recipient.IsArchivedOn(today))
+ .OrderBy(recipient => recipient.Name, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(recipient => recipient.Id.Value),
+ ];
+
+ return Task.FromResult(found);
+ }
+
+ public Task FindAsync(HouseholdId householdId, RecipientId id, CancellationToken cancellationToken) =>
+ Task.FromResult(database.Recipients.SingleOrDefault(
+ recipient => recipient.HouseholdId == householdId && recipient.Id == id));
+
+ public Task NameInUseByActiveRecipientAsync(
+ HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken) =>
+ Task.FromResult(database.Recipients.Any(recipient =>
+ recipient.HouseholdId == householdId
+ && recipient.ArchivedFrom is null
+ && recipient.Id != exceptRecipient
+ && string.Equals(recipient.Name, name, StringComparison.OrdinalIgnoreCase)));
+
+ public void Add(Recipient recipient) => database.StageChange(() => database.Recipients.Add(recipient));
+}
diff --git a/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj b/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj
new file mode 100644
index 0000000..59924f7
--- /dev/null
+++ b/backend/tests/PureFreshMilk.TestSupport/PureFreshMilk.TestSupport.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Library
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
From 06ab78877684a119f505623241e28cb92b49f340 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Fri, 30 Oct 2026 17:57:31 +0530
Subject: [PATCH 03/10] feat(infrastructure): persist households, recipients
and milk rates
Map the three aggregates to PostgreSQL with EF Core: typed id, quantity and
price converters, check constraints, restrict-on-delete foreign keys, a unique
(household_id, id) key for future composite foreign keys, a partial unique
index on the lower-cased name of active recipients and a unique index on
(household_id, effective_from). Add the repositories, a unit of work that turns
a unique-index race into a conflict, and the single-household provider. The
readiness check now also fails while migrations are pending.
The initial migration is AddHouseholdsRecipientsAndMilkRates. Tests run against
a real PostgreSQL, one throwaway database per class, and are tagged
Category=Database so the other jobs can exclude them.
---
.editorconfig | 4 +
backend/PureFreshMilk.slnx | 1 +
.../Households/SingleHouseholdProvider.cs | 34 +++
...frastructureServiceCollectionExtensions.cs | 20 +-
.../Configurations/HouseholdConfiguration.cs | 22 ++
.../Configurations/MilkRateConfiguration.cs | 37 +++
.../Configurations/RecipientConfiguration.cs | 58 ++++
...useholdsRecipientsAndMilkRates.Designer.cs | 188 ++++++++++++
...139_AddHouseholdsRecipientsAndMilkRates.cs | 109 +++++++
.../PureFreshMilkDbContextModelSnapshot.cs | 185 ++++++++++++
.../Persistence/PureFreshMilkDbContext.cs | 28 +-
.../Repositories/HouseholdRepository.cs | 9 +
.../Repositories/MilkRateRepository.cs | 29 ++
.../Repositories/RecipientRepository.cs | 41 +++
.../Persistence/UnitOfWork.cs | 35 +++
.../Persistence/ValueConverters.cs | 25 ++
.../PureFreshMilk.Infrastructure.csproj | 5 +
.../HandlerFlowTests.cs | 277 ++++++++++++++++++
.../MigrationTests.cs | 138 +++++++++
.../MilkRateConstraintTests.cs | 91 ++++++
.../PostgresDatabaseFixture.cs | 64 ++++
.../PureFreshMilk.Infrastructure.Tests.csproj | 15 +
.../RecipientConstraintTests.cs | 156 ++++++++++
.../RepositoryRoundTripTests.cs | 236 +++++++++++++++
.../Seed.cs | 23 ++
25 files changed, 1827 insertions(+), 3 deletions(-)
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs
create mode 100644 backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs
create mode 100644 backend/tests/PureFreshMilk.Infrastructure.Tests/Seed.cs
diff --git a/.editorconfig b/.editorconfig
index dd5bf73..802cfd7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -61,3 +61,7 @@ dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
[backend/tests/**.cs]
dotnet_naming_rule.types_and_members_are_pascal_case.severity = none
dotnet_diagnostic.CA1707.severity = none
+
+# EF Core migrations are generated by `dotnet ef`; they are not reformatted to match the C# style above.
+[backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/*.cs]
+generated_code = true
diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx
index 135e71a..a92e71c 100644
--- a/backend/PureFreshMilk.slnx
+++ b/backend/PureFreshMilk.slnx
@@ -9,6 +9,7 @@
+
diff --git a/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs
new file mode 100644
index 0000000..83d1643
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs
@@ -0,0 +1,34 @@
+using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Infrastructure.Persistence;
+
+namespace PureFreshMilk.Infrastructure.Households;
+
+///
+/// Single-household mode: before authentication exists, the current household is the only one in the
+/// database. It will be replaced by an implementation that reads the household from the signed-in user (ADR 0004).
+///
+internal sealed class SingleHouseholdProvider(PureFreshMilkDbContext context) : ICurrentHousehold
+{
+ private Household? household;
+
+ public async Task FindAsync(CancellationToken cancellationToken)
+ {
+ if (household is not null)
+ {
+ return household;
+ }
+
+ // Ask for two so that a second household is noticed rather than silently ignored.
+ var found = await context.Households.Take(2).ToListAsync(cancellationToken);
+
+ if (found.Count > 1)
+ {
+ throw new InvalidOperationException(
+ "Single-household mode found more than one household. Remove the extra rows or enable authentication.");
+ }
+
+ return household = found.SingleOrDefault();
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs
index 44d602b..712290f 100644
--- a/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs
+++ b/backend/src/PureFreshMilk.Infrastructure/InfrastructureServiceCollectionExtensions.cs
@@ -1,7 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.Infrastructure.Households;
using PureFreshMilk.Infrastructure.Persistence;
+using PureFreshMilk.Infrastructure.Persistence.Repositories;
namespace PureFreshMilk.Infrastructure;
@@ -27,8 +33,20 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddDbContext(options => options.UseNpgsql(connectionString));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Ready means PostgreSQL answers and every migration has been applied. Without the second part the
+ // API would start and then fail every request with "relation does not exist".
services.AddHealthChecks()
- .AddDbContextCheck(name: "database", tags: [HealthCheckTags.Ready]);
+ .AddDbContextCheck(
+ name: "database",
+ tags: [HealthCheckTags.Ready],
+ customTestQuery: async (context, cancellationToken) =>
+ !(await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any());
return services;
}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs
new file mode 100644
index 0000000..5683546
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/HouseholdConfiguration.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Configurations;
+
+internal sealed class HouseholdConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("households", table =>
+ table.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80"));
+
+ builder.HasKey(household => household.Id).HasName("pk_households");
+
+ builder.Property(household => household.Id).HasColumnName("id").ValueGeneratedNever();
+ builder.Property(household => household.Name).HasColumnName("name").HasMaxLength(80).IsRequired();
+ builder.Property(household => household.TimeZoneId).HasColumnName("time_zone_id").HasMaxLength(64).IsRequired();
+ builder.Property(household => household.CreatedAt).HasColumnName("created_at");
+ builder.Property(household => household.UpdatedAt).HasColumnName("updated_at");
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs
new file mode 100644
index 0000000..b56dc19
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/MilkRateConfiguration.cs
@@ -0,0 +1,37 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Configurations;
+
+internal sealed class MilkRateConfiguration : IEntityTypeConfiguration
+{
+ public const string EffectiveFromIndexName = "ux_milk_rates_household_id_effective_from";
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("milk_rates", table =>
+ table.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000"));
+
+ builder.HasKey(rate => rate.Id).HasName("pk_milk_rates");
+
+ builder.Property(rate => rate.Id).HasColumnName("id").ValueGeneratedNever();
+ builder.Property(rate => rate.HouseholdId).HasColumnName("household_id").IsRequired();
+ builder.Property(rate => rate.EffectiveFrom).HasColumnName("effective_from");
+ builder.Property(rate => rate.Price).HasColumnName("price_per_litre_paise");
+ builder.Property(rate => rate.CreatedAt).HasColumnName("created_at");
+ builder.Property(rate => rate.UpdatedAt).HasColumnName("updated_at");
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(rate => rate.HouseholdId)
+ .HasConstraintName("fk_milk_rates_households_household_id")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // One price per household per start date.
+ builder.HasIndex(rate => new { rate.HouseholdId, rate.EffectiveFrom })
+ .IsUnique()
+ .HasDatabaseName(EffectiveFromIndexName);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs
new file mode 100644
index 0000000..ba185a4
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Configurations/RecipientConfiguration.cs
@@ -0,0 +1,58 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Configurations;
+
+internal sealed class RecipientConfiguration : IEntityTypeConfiguration
+{
+ /// The generated column holding the lower-cased name, used to keep active names unique.
+ public const string NameKey = "NameKey";
+
+ public const string ActiveNameIndexName = "ux_recipients_household_id_name_key_active";
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("recipients", table =>
+ {
+ table.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ table.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000");
+ table.HasCheckConstraint(
+ "ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on");
+ });
+
+ builder.HasKey(recipient => recipient.Id).HasName("pk_recipients");
+
+ // The target of the composite foreign keys that child tables (deliveries) will use, so that a
+ // child row can never point at a recipient of another household.
+ builder.HasAlternateKey(recipient => new { recipient.HouseholdId, recipient.Id })
+ .HasName("uq_recipients_household_id_id");
+
+ builder.Property(recipient => recipient.Id).HasColumnName("id").ValueGeneratedNever();
+ builder.Property(recipient => recipient.HouseholdId).HasColumnName("household_id").IsRequired();
+ builder.Property(recipient => recipient.Name).HasColumnName("name").HasMaxLength(80).IsRequired();
+ builder.Property(recipient => recipient.ExpectedQuantity).HasColumnName("expected_ml");
+ builder.Property(recipient => recipient.StartsOn).HasColumnName("starts_on");
+ builder.Property(recipient => recipient.ArchivedFrom).HasColumnName("archived_from");
+ builder.Property(recipient => recipient.CreatedAt).HasColumnName("created_at");
+ builder.Property(recipient => recipient.UpdatedAt).HasColumnName("updated_at");
+
+ builder.Property(NameKey)
+ .HasColumnName("name_key")
+ .HasMaxLength(80)
+ .HasComputedColumnSql("lower(name)", stored: true);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(recipient => recipient.HouseholdId)
+ .HasConstraintName("fk_recipients_households_household_id")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // Active names are unique within a household, ignoring case. An archived name can be reused.
+ builder.HasIndex(nameof(Recipient.HouseholdId), NameKey)
+ .IsUnique()
+ .HasFilter("archived_from IS NULL")
+ .HasDatabaseName(ActiveNameIndexName);
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs
new file mode 100644
index 0000000..591a1e6
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.Designer.cs
@@ -0,0 +1,188 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PureFreshMilk.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace PureFreshMilk.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(PureFreshMilkDbContext))]
+ [Migration("20261030122139_AddHouseholdsRecipientsAndMilkRates")]
+ partial class AddHouseholdsRecipientsAndMilkRates
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Households.Household", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name");
+
+ b.Property("TimeZoneId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("time_zone_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_households");
+
+ b.ToTable("households", null, t =>
+ {
+ t.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EffectiveFrom")
+ .HasColumnType("date")
+ .HasColumnName("effective_from");
+
+ b.Property("HouseholdId")
+ .HasColumnType("uuid")
+ .HasColumnName("household_id");
+
+ b.Property("Price")
+ .HasColumnType("bigint")
+ .HasColumnName("price_per_litre_paise");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_milk_rates");
+
+ b.HasIndex("HouseholdId", "EffectiveFrom")
+ .IsUnique()
+ .HasDatabaseName("ux_milk_rates_household_id_effective_from");
+
+ b.ToTable("milk_rates", null, t =>
+ {
+ t.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ArchivedFrom")
+ .HasColumnType("date")
+ .HasColumnName("archived_from");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpectedQuantity")
+ .HasColumnType("integer")
+ .HasColumnName("expected_ml");
+
+ b.Property("HouseholdId")
+ .HasColumnType("uuid")
+ .HasColumnName("household_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name");
+
+ b.Property("NameKey")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name_key")
+ .HasComputedColumnSql("lower(name)", true);
+
+ b.Property("StartsOn")
+ .HasColumnType("date")
+ .HasColumnName("starts_on");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_recipients");
+
+ b.HasAlternateKey("HouseholdId", "Id")
+ .HasName("uq_recipients_household_id_id");
+
+ b.HasIndex("HouseholdId", "NameKey")
+ .IsUnique()
+ .HasDatabaseName("ux_recipients_household_id_name_key_active")
+ .HasFilter("archived_from IS NULL");
+
+ b.ToTable("recipients", null, t =>
+ {
+ t.HasCheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on");
+
+ t.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000");
+
+ t.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b =>
+ {
+ b.HasOne("PureFreshMilk.Domain.Households.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_milk_rates_households_household_id");
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b =>
+ {
+ b.HasOne("PureFreshMilk.Domain.Households.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_recipients_households_household_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs
new file mode 100644
index 0000000..3fb91da
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/20261030122139_AddHouseholdsRecipientsAndMilkRates.cs
@@ -0,0 +1,109 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PureFreshMilk.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddHouseholdsRecipientsAndMilkRates : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "households",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false),
+ time_zone_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_households", x => x.id);
+ table.CheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ });
+
+ migrationBuilder.CreateTable(
+ name: "milk_rates",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ household_id = table.Column(type: "uuid", nullable: false),
+ effective_from = table.Column(type: "date", nullable: false),
+ price_per_litre_paise = table.Column(type: "bigint", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_milk_rates", x => x.id);
+ table.CheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000");
+ table.ForeignKey(
+ name: "fk_milk_rates_households_household_id",
+ column: x => x.household_id,
+ principalTable: "households",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "recipients",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ household_id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false),
+ expected_ml = table.Column(type: "integer", nullable: false),
+ starts_on = table.Column(type: "date", nullable: false),
+ archived_from = table.Column(type: "date", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false),
+ name_key = table.Column(type: "character varying(80)", maxLength: 80, nullable: true, computedColumnSql: "lower(name)", stored: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_recipients", x => x.id);
+ table.UniqueConstraint("uq_recipients_household_id_id", x => new { x.household_id, x.id });
+ table.CheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on");
+ table.CheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000");
+ table.CheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ table.ForeignKey(
+ name: "fk_recipients_households_household_id",
+ column: x => x.household_id,
+ principalTable: "households",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ux_milk_rates_household_id_effective_from",
+ table: "milk_rates",
+ columns: new[] { "household_id", "effective_from" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ux_recipients_household_id_name_key_active",
+ table: "recipients",
+ columns: new[] { "household_id", "name_key" },
+ unique: true,
+ filter: "archived_from IS NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "milk_rates");
+
+ migrationBuilder.DropTable(
+ name: "recipients");
+
+ migrationBuilder.DropTable(
+ name: "households");
+ }
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs
new file mode 100644
index 0000000..d1dbff9
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/PureFreshMilkDbContextModelSnapshot.cs
@@ -0,0 +1,185 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PureFreshMilk.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace PureFreshMilk.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(PureFreshMilkDbContext))]
+ partial class PureFreshMilkDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Households.Household", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name");
+
+ b.Property("TimeZoneId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("time_zone_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_households");
+
+ b.ToTable("households", null, t =>
+ {
+ t.HasCheckConstraint("ck_households_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EffectiveFrom")
+ .HasColumnType("date")
+ .HasColumnName("effective_from");
+
+ b.Property("HouseholdId")
+ .HasColumnType("uuid")
+ .HasColumnName("household_id");
+
+ b.Property("Price")
+ .HasColumnType("bigint")
+ .HasColumnName("price_per_litre_paise");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_milk_rates");
+
+ b.HasIndex("HouseholdId", "EffectiveFrom")
+ .IsUnique()
+ .HasDatabaseName("ux_milk_rates_household_id_effective_from");
+
+ b.ToTable("milk_rates", null, t =>
+ {
+ t.HasCheckConstraint("ck_milk_rates_price", "price_per_litre_paise BETWEEN 1 AND 1000000");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ArchivedFrom")
+ .HasColumnType("date")
+ .HasColumnName("archived_from");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpectedQuantity")
+ .HasColumnType("integer")
+ .HasColumnName("expected_ml");
+
+ b.Property("HouseholdId")
+ .HasColumnType("uuid")
+ .HasColumnName("household_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name");
+
+ b.Property("NameKey")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)")
+ .HasColumnName("name_key")
+ .HasComputedColumnSql("lower(name)", true);
+
+ b.Property("StartsOn")
+ .HasColumnType("date")
+ .HasColumnName("starts_on");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_recipients");
+
+ b.HasAlternateKey("HouseholdId", "Id")
+ .HasName("uq_recipients_household_id_id");
+
+ b.HasIndex("HouseholdId", "NameKey")
+ .IsUnique()
+ .HasDatabaseName("ux_recipients_household_id_name_key_active")
+ .HasFilter("archived_from IS NULL");
+
+ b.ToTable("recipients", null, t =>
+ {
+ t.HasCheckConstraint("ck_recipients_archived_after_start", "archived_from IS NULL OR archived_from > starts_on");
+
+ t.HasCheckConstraint("ck_recipients_expected_ml", "expected_ml BETWEEN 1 AND 20000");
+
+ t.HasCheckConstraint("ck_recipients_name_length", "length(btrim(name)) BETWEEN 1 AND 80");
+ });
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Rates.MilkRate", b =>
+ {
+ b.HasOne("PureFreshMilk.Domain.Households.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_milk_rates_households_household_id");
+ });
+
+ modelBuilder.Entity("PureFreshMilk.Domain.Recipients.Recipient", b =>
+ {
+ b.HasOne("PureFreshMilk.Domain.Households.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_recipients_households_household_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs
index bf003a2..5d020f2 100644
--- a/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/PureFreshMilkDbContext.cs
@@ -1,11 +1,35 @@
using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+using PureFreshMilk.Domain.Recipients;
namespace PureFreshMilk.Infrastructure.Persistence;
///
/// EF Core context for the PostgreSQL database. Internal so that only this
/// assembly (repositories, configurations, migrations) touches persistence directly.
-/// No entities are mapped yet; see docs/architecture/overview.md for the mapping conventions.
+/// Each table is mapped by a configuration class under Configurations/;
+/// see docs/architecture/overview.md for the mapping conventions.
///
internal sealed class PureFreshMilkDbContext(DbContextOptions options)
- : DbContext(options);
+ : DbContext(options)
+{
+ public DbSet Households => Set();
+
+ public DbSet Recipients => Set();
+
+ public DbSet MilkRates => Set();
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ configurationBuilder.Properties().HaveConversion();
+ configurationBuilder.Properties().HaveConversion();
+ configurationBuilder.Properties().HaveConversion();
+ configurationBuilder.Properties().HaveConversion();
+ configurationBuilder.Properties().HaveConversion();
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) =>
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(PureFreshMilkDbContext).Assembly);
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs
new file mode 100644
index 0000000..fdd8b49
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/HouseholdRepository.cs
@@ -0,0 +1,9 @@
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Domain.Households;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Repositories;
+
+internal sealed class HouseholdRepository(PureFreshMilkDbContext context) : IHouseholdRepository
+{
+ public void Add(Household household) => context.Households.Add(household);
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs
new file mode 100644
index 0000000..45f3c66
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/MilkRateRepository.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Repositories;
+
+internal sealed class MilkRateRepository(PureFreshMilkDbContext context) : IMilkRateRepository
+{
+ public async Task> ListAsync(HouseholdId householdId, CancellationToken cancellationToken) =>
+ await context.MilkRates
+ .AsNoTracking()
+ .Where(rate => rate.HouseholdId == householdId)
+ .ToListAsync(cancellationToken);
+
+ public async Task FindAsync(HouseholdId householdId, MilkRateId id, CancellationToken cancellationToken) =>
+ await context.MilkRates.SingleOrDefaultAsync(
+ rate => rate.HouseholdId == householdId && rate.Id == id, cancellationToken);
+
+ public async Task EffectiveFromTakenAsync(
+ HouseholdId householdId, DateOnly effectiveFrom, MilkRateId? exceptRate, CancellationToken cancellationToken) =>
+ await context.MilkRates.AnyAsync(
+ rate => rate.HouseholdId == householdId && rate.EffectiveFrom == effectiveFrom && rate.Id != exceptRate,
+ cancellationToken);
+
+ public void Add(MilkRate rate) => context.MilkRates.Add(rate);
+
+ public void Remove(MilkRate rate) => context.MilkRates.Remove(rate);
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs
new file mode 100644
index 0000000..5e4376a
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/Repositories/RecipientRepository.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+using PureFreshMilk.Infrastructure.Persistence.Configurations;
+
+namespace PureFreshMilk.Infrastructure.Persistence.Repositories;
+
+internal sealed class RecipientRepository(PureFreshMilkDbContext context) : IRecipientRepository
+{
+ public async Task> ListAsync(
+ HouseholdId householdId, bool includeArchived, DateOnly today, CancellationToken cancellationToken) =>
+ await context.Recipients
+ .AsNoTracking()
+ .Where(recipient => recipient.HouseholdId == householdId)
+ .Where(recipient => includeArchived || recipient.ArchivedFrom == null || recipient.ArchivedFrom > today)
+ .OrderBy(recipient => EF.Property(recipient, RecipientConfiguration.NameKey))
+ .ThenBy(recipient => recipient.Id)
+ .ToListAsync(cancellationToken);
+
+ public async Task FindAsync(
+ HouseholdId householdId, RecipientId id, CancellationToken cancellationToken) =>
+ await context.Recipients.SingleOrDefaultAsync(
+ recipient => recipient.HouseholdId == householdId && recipient.Id == id, cancellationToken);
+
+ public async Task NameInUseByActiveRecipientAsync(
+ HouseholdId householdId, string name, RecipientId? exceptRecipient, CancellationToken cancellationToken)
+ {
+ // Compare the generated lower-case column, the same one the unique index is built on.
+ var nameKey = name.ToLowerInvariant();
+
+ return await context.Recipients.AnyAsync(
+ recipient => recipient.HouseholdId == householdId
+ && recipient.ArchivedFrom == null
+ && recipient.Id != exceptRecipient
+ && EF.Property(recipient, RecipientConfiguration.NameKey) == nameKey,
+ cancellationToken);
+ }
+
+ public void Add(Recipient recipient) => context.Recipients.Add(recipient);
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs
new file mode 100644
index 0000000..0400243
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/UnitOfWork.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Infrastructure.Persistence.Configurations;
+
+namespace PureFreshMilk.Infrastructure.Persistence;
+
+internal sealed class UnitOfWork(PureFreshMilkDbContext context) : IUnitOfWork
+{
+ public async Task SaveChangesAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken);
+ }
+ catch (DbUpdateException exception) when (ToConflict(exception) is { } conflict)
+ {
+ // Two requests raced past the friendly check in the use case; the unique index decided.
+ throw conflict;
+ }
+ }
+
+ private static ConflictException? ToConflict(DbUpdateException exception) =>
+ exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } violation
+ ? violation.ConstraintName switch
+ {
+ RecipientConfiguration.ActiveNameIndexName => new ConflictException(
+ ErrorCodes.RecipientNameTaken, "Another recipient who is not archived already has that name."),
+ MilkRateConfiguration.EffectiveFromIndexName => new ConflictException(
+ ErrorCodes.RateDateTaken, "A price already starts on that date."),
+ _ => null,
+ }
+ : null;
+}
diff --git a/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs b/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs
new file mode 100644
index 0000000..5acc9b5
--- /dev/null
+++ b/backend/src/PureFreshMilk.Infrastructure/Persistence/ValueConverters.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PureFreshMilk.Domain.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Rates;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Infrastructure.Persistence;
+
+// Domain value objects are stored as the plain value they wrap, so the tables hold uuid, integer and bigint
+// columns. Registered for every property of these types in PureFreshMilkDbContext.ConfigureConventions.
+
+internal sealed class HouseholdIdConverter()
+ : ValueConverter(id => id.Value, value => HouseholdId.From(value));
+
+internal sealed class RecipientIdConverter()
+ : ValueConverter(id => id.Value, value => RecipientId.From(value));
+
+internal sealed class MilkRateIdConverter()
+ : ValueConverter(id => id.Value, value => MilkRateId.From(value));
+
+internal sealed class QuantityConverter()
+ : ValueConverter(quantity => quantity.Millilitres, value => Quantity.FromMillilitres(value));
+
+internal sealed class PricePerLitreConverter()
+ : ValueConverter(price => price.PaisePerLitre, value => PricePerLitre.FromPaise(value));
diff --git a/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj b/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj
index f59c8d6..4611f4f 100644
--- a/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj
+++ b/backend/src/PureFreshMilk.Infrastructure/PureFreshMilk.Infrastructure.csproj
@@ -11,6 +11,11 @@
+
+
+
+
+
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs
new file mode 100644
index 0000000..c337a84
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/HandlerFlowTests.cs
@@ -0,0 +1,277 @@
+using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Rates;
+using PureFreshMilk.Application.Recipients;
+using PureFreshMilk.Infrastructure.Households;
+using PureFreshMilk.Infrastructure.Persistence;
+using PureFreshMilk.Infrastructure.Persistence.Repositories;
+using PureFreshMilk.TestSupport;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+///
+/// The use cases wired to the real repositories and PostgreSQL, the way the API runs them. Single-household
+/// mode needs the database to hold exactly one household, so each test starts from empty tables.
+///
+[Trait("Category", "Database")]
+public sealed class HandlerFlowTests(PostgresDatabaseFixture database) : IClassFixture
+{
+ private static readonly DateOnly October1 = new(2026, 10, 1);
+
+ private readonly FixedTimeProvider clock = new(Seed.Now);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ /// One request's worth of services over a fresh context, as dependency injection scopes them.
+ private sealed class Request(PureFreshMilkDbContext context, TimeProvider clock) : IAsyncDisposable
+ {
+ private readonly HouseholdRepository households = new(context);
+ private readonly RecipientRepository recipients = new(context);
+ private readonly MilkRateRepository rates = new(context);
+ private readonly SingleHouseholdProvider current = new(context);
+ private readonly UnitOfWork unitOfWork = new(context);
+
+ public SetUpHouseholdHandler SetUp => new(current, households, unitOfWork, clock);
+
+ public GetHouseholdHandler GetHousehold => new(current, clock);
+
+ public AddRecipientHandler AddRecipient => new(current, recipients, unitOfWork, clock);
+
+ public ListRecipientsHandler ListRecipients => new(current, recipients, clock);
+
+ public UpdateRecipientHandler UpdateRecipient => new(current, recipients, unitOfWork, clock);
+
+ public ArchiveRecipientHandler ArchiveRecipient => new(current, recipients, unitOfWork, clock);
+
+ public UnarchiveRecipientHandler UnarchiveRecipient => new(current, recipients, unitOfWork, clock);
+
+ public AddMilkRateHandler AddRate => new(current, rates, unitOfWork, clock);
+
+ public ListMilkRatesHandler ListRates => new(current, rates);
+
+ public GetRateInEffectHandler RateInEffect => new(current, rates, clock);
+
+ public UpdateMilkRateHandler UpdateRate => new(current, rates, unitOfWork, clock);
+
+ public DeleteMilkRateHandler DeleteRate => new(current, rates, unitOfWork);
+
+ public ValueTask DisposeAsync() => context.DisposeAsync();
+ }
+
+ private Request NewRequest() => new(database.CreateContext(), clock);
+
+ private async Task StartEmptyAsync()
+ {
+ await using var context = database.CreateContext();
+ await context.Database.ExecuteSqlRawAsync("truncate table milk_rates, recipients, households", Ct);
+ }
+
+ private async Task SetUpHouseholdAsync()
+ {
+ await StartEmptyAsync();
+ await using var request = NewRequest();
+ await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Example household", null), Ct);
+ }
+
+ [Fact]
+ public async Task Before_set_up_the_household_is_not_found()
+ {
+ await StartEmptyAsync();
+ await using var request = NewRequest();
+
+ var exception = await Assert.ThrowsAsync(() => request.GetHousehold.HandleAsync(Ct));
+
+ Assert.Equal(ErrorCodes.HouseholdNotSetUp, exception.Code);
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-5")]
+ public async Task Setting_up_creates_one_household_and_setting_up_again_updates_it()
+ {
+ await StartEmptyAsync();
+
+ await using (var request = NewRequest())
+ {
+ var created = await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Example household", null), Ct);
+ Assert.True(created.Created);
+ Assert.Equal("Asia/Kolkata", created.Household.TimeZoneId);
+ }
+
+ await using (var request = NewRequest())
+ {
+ var updated = await request.SetUp.HandleAsync(new SetUpHouseholdCommand("Renamed", "Europe/London"), Ct);
+ Assert.False(updated.Created);
+ }
+
+ await using var reading = NewRequest();
+ var household = await reading.GetHousehold.HandleAsync(Ct);
+ Assert.Equal("Renamed", household.Name);
+ Assert.Equal("Europe/London", household.TimeZoneId);
+
+ await using var context = database.CreateContext();
+ Assert.Equal(1, await context.Households.CountAsync(Ct));
+ }
+
+ [Fact]
+ [Trait("Invariant", "H-5")]
+ public async Task A_second_household_row_is_noticed_instead_of_silently_ignored()
+ {
+ await SetUpHouseholdAsync();
+ await using (var context = database.CreateContext())
+ {
+ context.Households.Add(Seed.NewHousehold("Intruder"));
+ await context.SaveChangesAsync(Ct);
+ }
+
+ await using var request = NewRequest();
+
+ await Assert.ThrowsAsync(() => request.GetHousehold.HandleAsync(Ct));
+ }
+
+ [Fact]
+ public async Task Recipients_can_be_added_listed_updated_archived_and_restored()
+ {
+ await SetUpHouseholdAsync();
+
+ RecipientDetails recipientA;
+ await using (var request = NewRequest())
+ {
+ recipientA = await request.AddRecipient.HandleAsync(new AddRecipientCommand(" Recipient A ", 1000, October1), Ct);
+ }
+
+ await using (var request = NewRequest())
+ {
+ await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient B", 500, null), Ct);
+ }
+
+ await using (var request = NewRequest())
+ {
+ var list = await request.ListRecipients.HandleAsync(includeArchived: false, Ct);
+ Assert.Equal(["Recipient A", "Recipient B"], list.Select(recipient => recipient.Name));
+ Assert.Equal(new DateOnly(2026, 10, 30), list[1].StartsOn); // defaulted to today in Asia/Kolkata
+ }
+
+ await using (var request = NewRequest())
+ {
+ var updated = await request.UpdateRecipient.HandleAsync(
+ new UpdateRecipientCommand(recipientA.Id, "Recipient Z", 750, October1), Ct);
+ Assert.Equal(new RecipientDetails(recipientA.Id, "Recipient Z", 750, October1, null), updated);
+ }
+
+ await using (var request = NewRequest())
+ {
+ var archived = await request.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(recipientA.Id, null), Ct);
+ Assert.Equal(new DateOnly(2026, 10, 30), archived.ArchivedFrom);
+ }
+
+ await using (var request = NewRequest())
+ {
+ Assert.Equal(["Recipient B"], (await request.ListRecipients.HandleAsync(false, Ct)).Select(recipient => recipient.Name));
+ Assert.Equal(2, (await request.ListRecipients.HandleAsync(true, Ct)).Count);
+ }
+
+ await using (var request = NewRequest())
+ {
+ var restored = await request.UnarchiveRecipient.HandleAsync(recipientA.Id, Ct);
+ Assert.Null(restored.ArchivedFrom);
+ }
+
+ await using var reading = NewRequest();
+ Assert.Equal(2, (await reading.ListRecipients.HandleAsync(false, Ct)).Count);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task A_duplicate_active_name_is_a_conflict_through_the_real_database_query()
+ {
+ await SetUpHouseholdAsync();
+ await using (var request = NewRequest())
+ {
+ await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient A", 1000, October1), Ct);
+ }
+
+ await using var duplicate = NewRequest();
+ var exception = await Assert.ThrowsAsync(
+ () => duplicate.AddRecipient.HandleAsync(new AddRecipientCommand(" RECIPIENT a", 500, October1), Ct));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ }
+
+ [Fact]
+ public async Task Archiving_leaves_the_recipient_in_the_database()
+ {
+ await SetUpHouseholdAsync();
+ await using (var request = NewRequest())
+ {
+ var added = await request.AddRecipient.HandleAsync(new AddRecipientCommand("Recipient A", 1000, October1), Ct);
+ await using var archiving = NewRequest();
+ await archiving.ArchiveRecipient.HandleAsync(new ArchiveRecipientCommand(added.Id, null), Ct);
+ }
+
+ await using var context = database.CreateContext();
+ Assert.Equal(1, await context.Recipients.CountAsync(Ct));
+ }
+
+ [Fact]
+ public async Task Milk_rates_can_be_added_listed_looked_up_changed_and_deleted()
+ {
+ await SetUpHouseholdAsync();
+
+ MilkRateDetails october;
+ MilkRateDetails mid;
+ await using (var request = NewRequest())
+ {
+ october = await request.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6000), Ct);
+ }
+
+ await using (var request = NewRequest())
+ {
+ mid = await request.AddRate.HandleAsync(new AddMilkRateCommand(new DateOnly(2026, 10, 15), 6400), Ct);
+ }
+
+ await using (var request = NewRequest())
+ {
+ var list = await request.ListRates.HandleAsync(Ct);
+ Assert.Equal([mid.Id, october.Id], list.Select(rate => rate.Id));
+ }
+
+ await using (var request = NewRequest())
+ {
+ Assert.Equal(6000, (await request.RateInEffect.HandleAsync(new DateOnly(2026, 10, 14), Ct)).PricePerLitrePaise);
+ Assert.Equal(6400, (await request.RateInEffect.HandleAsync(null, Ct)).PricePerLitrePaise);
+ await Assert.ThrowsAsync(() => request.RateInEffect.HandleAsync(new DateOnly(2026, 9, 30), Ct));
+ }
+
+ await using (var request = NewRequest())
+ {
+ var updated = await request.UpdateRate.HandleAsync(new UpdateMilkRateCommand(mid.Id, new DateOnly(2026, 10, 16), 6500), Ct);
+ Assert.Equal(new MilkRateDetails(mid.Id, new DateOnly(2026, 10, 16), 6500), updated);
+ }
+
+ await using (var request = NewRequest())
+ {
+ await request.DeleteRate.HandleAsync(mid.Id, Ct);
+ }
+
+ await using var reading = NewRequest();
+ Assert.Equal(october.Id, Assert.Single(await reading.ListRates.HandleAsync(Ct)).Id);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-3")]
+ public async Task A_duplicate_rate_date_is_a_conflict_through_the_real_database_query()
+ {
+ await SetUpHouseholdAsync();
+ await using (var request = NewRequest())
+ {
+ await request.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6000), Ct);
+ }
+
+ await using var duplicate = NewRequest();
+ var exception = await Assert.ThrowsAsync(
+ () => duplicate.AddRate.HandleAsync(new AddMilkRateCommand(October1, 6400), Ct));
+
+ Assert.Equal(ErrorCodes.RateDateTaken, exception.Code);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs
new file mode 100644
index 0000000..fe35669
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/MigrationTests.cs
@@ -0,0 +1,138 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+[Trait("Category", "Database")]
+public sealed class MigrationTests(PostgresDatabaseFixture database) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ [Trait("Invariant", "P-1")]
+ public async Task Migrations_apply_to_an_empty_database_and_the_model_has_no_further_changes()
+ {
+ await using var context = database.CreateContext();
+
+ var applied = await context.Database.GetAppliedMigrationsAsync(Ct);
+
+ Assert.Contains(applied, name => name.EndsWith("_AddHouseholdsRecipientsAndMilkRates", StringComparison.Ordinal));
+ Assert.Empty(await context.Database.GetPendingMigrationsAsync(Ct));
+ Assert.False(context.Database.HasPendingModelChanges(), "The model differs from the last migration: add a migration.");
+ }
+
+ [Fact]
+ public async Task The_tables_have_the_columns_and_types_that_the_design_lists()
+ {
+ await using var context = database.CreateContext();
+
+ var columns = await context.Database
+ .SqlQueryRaw(
+ """
+ select table_name || '.' || column_name || ' ' || data_type || ' ' || is_nullable as "Value"
+ from information_schema.columns
+ where table_schema = 'public' and table_name in ('households', 'recipients', 'milk_rates')
+ order by 1
+ """)
+ .ToListAsync(Ct);
+
+ Assert.Equal(
+ [
+ "households.created_at timestamp with time zone NO",
+ "households.id uuid NO",
+ "households.name character varying NO",
+ "households.time_zone_id character varying NO",
+ "households.updated_at timestamp with time zone NO",
+ "milk_rates.created_at timestamp with time zone NO",
+ "milk_rates.effective_from date NO",
+ "milk_rates.household_id uuid NO",
+ "milk_rates.id uuid NO",
+ "milk_rates.price_per_litre_paise bigint NO",
+ "milk_rates.updated_at timestamp with time zone NO",
+ "recipients.archived_from date YES",
+ "recipients.created_at timestamp with time zone NO",
+ "recipients.expected_ml integer NO",
+ "recipients.household_id uuid NO",
+ "recipients.id uuid NO",
+ "recipients.name character varying NO",
+ "recipients.name_key character varying YES",
+ "recipients.starts_on date NO",
+ "recipients.updated_at timestamp with time zone NO",
+ ],
+ columns);
+ }
+
+ [Fact]
+ public async Task The_constraints_and_indexes_of_the_design_exist()
+ {
+ await using var context = database.CreateContext();
+
+ var constraints = await context.Database
+ .SqlQueryRaw(
+ """
+ select conname as "Value" from pg_constraint
+ where conrelid in ('households'::regclass, 'recipients'::regclass, 'milk_rates'::regclass)
+ and contype in ('p', 'u', 'f', 'c') -- PostgreSQL 18 also lists NOT NULL ('n') here
+ order by conname
+ """)
+ .ToListAsync(Ct);
+
+ Assert.Equal(
+ [
+ "ck_households_name_length",
+ "ck_milk_rates_price",
+ "ck_recipients_archived_after_start",
+ "ck_recipients_expected_ml",
+ "ck_recipients_name_length",
+ "fk_milk_rates_households_household_id",
+ "fk_recipients_households_household_id",
+ "pk_households",
+ "pk_milk_rates",
+ "pk_recipients",
+ "uq_recipients_household_id_id",
+ ],
+ constraints);
+ }
+
+ [Fact]
+ public async Task Foreign_keys_restrict_deletion_so_history_outlives_any_delete()
+ {
+ await using var context = database.CreateContext();
+
+ // 'r' is RESTRICT in pg_constraint.confdeltype.
+ var rules = await context.Database
+ .SqlQueryRaw(
+ """
+ select conname || ' ' || confdeltype::text as "Value" from pg_constraint
+ where connamespace = 'public'::regnamespace and contype = 'f'
+ order by conname
+ """)
+ .ToListAsync(Ct);
+
+ Assert.Equal(
+ ["fk_milk_rates_households_household_id r", "fk_recipients_households_household_id r"],
+ rules);
+ }
+
+ [Fact]
+ public async Task Active_recipient_names_are_unique_through_a_partial_index_on_the_generated_name_key()
+ {
+ await using var context = database.CreateContext();
+
+ var definitions = await context.Database
+ .SqlQueryRaw(
+ """
+ select indexdef as "Value" from pg_indexes
+ where schemaname = 'public' and indexname in
+ ('ux_recipients_household_id_name_key_active', 'ux_milk_rates_household_id_effective_from')
+ order by indexname
+ """)
+ .ToListAsync(Ct);
+
+ Assert.Equal(2, definitions.Count);
+ Assert.Contains("UNIQUE INDEX ux_milk_rates_household_id_effective_from", definitions[0], StringComparison.Ordinal);
+ Assert.Contains("(household_id, effective_from)", definitions[0], StringComparison.Ordinal);
+ Assert.Contains("UNIQUE INDEX ux_recipients_household_id_name_key_active", definitions[1], StringComparison.Ordinal);
+ Assert.Contains("(household_id, name_key)", definitions[1], StringComparison.Ordinal);
+ Assert.Contains("WHERE (archived_from IS NULL)", definitions[1], StringComparison.Ordinal);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs
new file mode 100644
index 0000000..7a1cfc8
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/MilkRateConstraintTests.cs
@@ -0,0 +1,91 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Infrastructure.Persistence;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+[Trait("Category", "Database")]
+public sealed class MilkRateConstraintTests(PostgresDatabaseFixture database) : IClassFixture
+{
+ private static readonly DateOnly October1 = new(2026, 10, 1);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private async Task SaveHouseholdAsync()
+ {
+ await using var context = database.CreateContext();
+ var household = Seed.NewHousehold();
+ context.Households.Add(household);
+ await context.SaveChangesAsync(Ct);
+ return household;
+ }
+
+ private async Task SaveAsync(Action change)
+ {
+ await using var context = database.CreateContext();
+ change(context);
+ await new UnitOfWork(context).SaveChangesAsync(Ct);
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-3")]
+ public async Task A_second_rate_for_the_same_day_is_refused_by_the_unique_index()
+ {
+ var household = await SaveHouseholdAsync();
+ await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(household, October1, 6000)));
+
+ var exception = await Assert.ThrowsAsync(
+ () => SaveAsync(context => context.MilkRates.Add(Seed.NewRate(household, October1, 6400))));
+
+ Assert.Equal(ErrorCodes.RateDateTaken, exception.Code);
+ }
+
+ [Fact]
+ public async Task Two_households_can_have_a_rate_starting_on_the_same_day()
+ {
+ var first = await SaveHouseholdAsync();
+ var second = await SaveHouseholdAsync();
+
+ await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(first, October1)));
+ await SaveAsync(context => context.MilkRates.Add(Seed.NewRate(second, October1)));
+ }
+
+ [Theory]
+ [Trait("Invariant", "T-1")]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(1_000_001)]
+ public async Task The_database_refuses_a_price_outside_the_allowed_range_even_when_the_domain_is_bypassed(long paise)
+ {
+ var household = await SaveHouseholdAsync();
+ await using var context = database.CreateContext();
+
+ var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync(
+ $"""
+ insert into milk_rates (id, household_id, effective_from, price_per_litre_paise, created_at, updated_at)
+ values ({Guid.CreateVersion7()}, {household.Id.Value}, {October1}, {paise}, {Seed.Now}, {Seed.Now})
+ """,
+ Ct));
+
+ Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
+ Assert.Equal("ck_milk_rates_price", exception.ConstraintName);
+ }
+
+ [Fact]
+ public async Task A_rate_cannot_point_at_a_household_that_does_not_exist()
+ {
+ await using var context = database.CreateContext();
+
+ var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync(
+ $"""
+ insert into milk_rates (id, household_id, effective_from, price_per_litre_paise, created_at, updated_at)
+ values ({Guid.CreateVersion7()}, {Guid.CreateVersion7()}, {October1}, {6250L}, {Seed.Now}, {Seed.Now})
+ """,
+ Ct));
+
+ Assert.Equal(PostgresErrorCodes.ForeignKeyViolation, exception.SqlState);
+ Assert.Equal("fk_milk_rates_households_household_id", exception.ConstraintName);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs
new file mode 100644
index 0000000..57222c6
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/PostgresDatabaseFixture.cs
@@ -0,0 +1,64 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using PureFreshMilk.Infrastructure.Persistence;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+///
+/// Creates a throwaway PostgreSQL database for one test class, builds it from the migrations, and drops it
+/// afterwards. The server comes from ConnectionStrings__PureFreshMilkTests: the same Compose PostgreSQL
+/// that local development uses. A missing setting is a failure, never a silent skip, so these tests cannot
+/// quietly stop running in CI.
+///
+public sealed class PostgresDatabaseFixture : IAsyncLifetime
+{
+ public const string ConnectionStringVariable = "ConnectionStrings__PureFreshMilkTests";
+
+ private string serverConnectionString = string.Empty;
+ private string databaseName = string.Empty;
+
+ public string ConnectionString { get; private set; } = string.Empty;
+
+ public async ValueTask InitializeAsync()
+ {
+ var configured = Environment.GetEnvironmentVariable(ConnectionStringVariable);
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ throw new InvalidOperationException(
+ $"The database tests need PostgreSQL. Start it with 'docker compose up -d --wait' and set {ConnectionStringVariable} " +
+ "to its connection string (see docs/development.md). To run everything else, exclude the 'Database' category.");
+ }
+
+ serverConnectionString = configured;
+ databaseName = $"purefreshmilk_test_{Guid.NewGuid():N}";
+
+ await ExecuteOnServerAsync($"CREATE DATABASE \"{databaseName}\"");
+
+ ConnectionString = new NpgsqlConnectionStringBuilder(configured) { Database = databaseName }.ConnectionString;
+
+ await using var context = CreateContext();
+ await context.Database.MigrateAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (databaseName.Length == 0)
+ {
+ return;
+ }
+
+ NpgsqlConnection.ClearAllPools();
+ await ExecuteOnServerAsync($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)");
+ }
+
+ internal PureFreshMilkDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options);
+
+ private async Task ExecuteOnServerAsync(string sql)
+ {
+ await using var connection = new NpgsqlConnection(serverConnectionString);
+ await connection.OpenAsync();
+ await using var command = new NpgsqlCommand(sql, connection);
+ await command.ExecuteNonQueryAsync();
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj b/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj
new file mode 100644
index 0000000..6b17539
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/PureFreshMilk.Infrastructure.Tests.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs
new file mode 100644
index 0000000..a877cee
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/RecipientConstraintTests.cs
@@ -0,0 +1,156 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Infrastructure.Persistence;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+[Trait("Category", "Database")]
+public sealed class RecipientConstraintTests(PostgresDatabaseFixture database) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private async Task SaveHouseholdAsync()
+ {
+ await using var context = database.CreateContext();
+ var household = Seed.NewHousehold();
+ context.Households.Add(household);
+ await context.SaveChangesAsync(Ct);
+ return household;
+ }
+
+ private async Task SaveAsync(Action change)
+ {
+ await using var context = database.CreateContext();
+ change(context);
+ await new UnitOfWork(context).SaveChangesAsync(Ct);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task Active_names_are_unique_within_a_household_ignoring_case()
+ {
+ var household = await SaveHouseholdAsync();
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A")));
+
+ var exception = await Assert.ThrowsAsync(
+ () => SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "RECIPIENT a"))));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task The_same_name_can_be_used_in_another_household()
+ {
+ var first = await SaveHouseholdAsync();
+ var second = await SaveHouseholdAsync();
+
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(first, "Recipient A")));
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(second, "Recipient A")));
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task An_archived_recipient_does_not_block_its_name()
+ {
+ var household = await SaveHouseholdAsync();
+ var archived = Seed.NewRecipient(household, "Recipient A");
+ archived.Archive(new DateOnly(2026, 10, 15), Seed.Now);
+ await SaveAsync(context => context.Recipients.Add(archived));
+
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A")));
+ }
+
+ [Fact]
+ [Trait("Invariant", "R-8")]
+ public async Task Unarchiving_into_a_name_that_is_now_taken_is_refused_by_the_index()
+ {
+ var household = await SaveHouseholdAsync();
+ var archived = Seed.NewRecipient(household, "Recipient A");
+ archived.Archive(new DateOnly(2026, 10, 15), Seed.Now);
+ await SaveAsync(context => context.Recipients.Add(archived));
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household, "Recipient A")));
+
+ var exception = await Assert.ThrowsAsync(() => SaveAsync(context =>
+ {
+ var stored = context.Recipients.Single(recipient => recipient.Id == archived.Id);
+ stored.Unarchive(Seed.Now);
+ }));
+
+ Assert.Equal(ErrorCodes.RecipientNameTaken, exception.Code);
+ }
+
+ [Fact]
+ public async Task The_name_key_is_generated_from_the_name()
+ {
+ var household = await SaveHouseholdAsync();
+ var recipient = Seed.NewRecipient(household, "Recipient MIXED Case");
+ await SaveAsync(context => context.Recipients.Add(recipient));
+
+ await using var context = database.CreateContext();
+ var key = await context.Database
+ .SqlQuery($"select name_key as \"Value\" from recipients where id = {recipient.Id.Value}")
+ .SingleAsync(Ct);
+
+ Assert.Equal("recipient mixed case", key);
+ }
+
+ [Theory]
+ [Trait("Invariant", "R-2")]
+ [InlineData("Recipient A", 0, "2026-10-01", null, "ck_recipients_expected_ml")]
+ [InlineData("Recipient A", 20_001, "2026-10-01", null, "ck_recipients_expected_ml")]
+ [InlineData(" ", 500, "2026-10-01", null, "ck_recipients_name_length")]
+ [InlineData("Recipient A", 500, "2026-10-01", "2026-10-01", "ck_recipients_archived_after_start")]
+ [InlineData("Recipient A", 500, "2026-10-01", "2026-09-01", "ck_recipients_archived_after_start")]
+ public async Task The_database_refuses_rows_that_break_the_checks_even_when_the_domain_is_bypassed(
+ string name, int expectedMl, string startsOn, string? archivedFrom, string constraint)
+ {
+ var household = await SaveHouseholdAsync();
+ await using var context = database.CreateContext();
+ var start = DateOnly.Parse(startsOn, System.Globalization.CultureInfo.InvariantCulture);
+ DateOnly? archived = archivedFrom is null ? null : DateOnly.Parse(archivedFrom, System.Globalization.CultureInfo.InvariantCulture);
+
+ var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync(
+ $"""
+ insert into recipients (id, household_id, name, expected_ml, starts_on, archived_from, created_at, updated_at)
+ values ({Guid.CreateVersion7()}, {household.Id.Value}, {name}, {expectedMl}, {start}, {archived}, {Seed.Now}, {Seed.Now})
+ """,
+ Ct));
+
+ Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
+ Assert.Equal(constraint, exception.ConstraintName);
+ }
+
+ [Fact]
+ public async Task A_recipient_cannot_point_at_a_household_that_does_not_exist()
+ {
+ await using var context = database.CreateContext();
+
+ var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlAsync(
+ $"""
+ insert into recipients (id, household_id, name, expected_ml, starts_on, created_at, updated_at)
+ values ({Guid.CreateVersion7()}, {Guid.CreateVersion7()}, {"Recipient A"}, {500}, {Seed.Start}, {Seed.Now}, {Seed.Now})
+ """,
+ Ct));
+
+ Assert.Equal(PostgresErrorCodes.ForeignKeyViolation, exception.SqlState);
+ Assert.Equal("fk_recipients_households_household_id", exception.ConstraintName);
+ }
+
+ [Fact]
+ public async Task A_household_with_recipients_cannot_be_deleted()
+ {
+ var household = await SaveHouseholdAsync();
+ await SaveAsync(context => context.Recipients.Add(Seed.NewRecipient(household)));
+ await using var context = database.CreateContext();
+
+ var exception = await Assert.ThrowsAsync(
+ () => context.Database.ExecuteSqlAsync($"delete from households where id = {household.Id.Value}", Ct));
+
+ // RESTRICT, rather than NO ACTION, reports its own error code.
+ Assert.Equal(PostgresErrorCodes.RestrictViolation, exception.SqlState);
+ Assert.Equal("fk_recipients_households_household_id", exception.ConstraintName);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs b/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs
new file mode 100644
index 0000000..ea23c75
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Infrastructure.Tests/RepositoryRoundTripTests.cs
@@ -0,0 +1,236 @@
+using Microsoft.EntityFrameworkCore;
+using PureFreshMilk.Domain.Households;
+using PureFreshMilk.Domain.Recipients;
+using PureFreshMilk.Infrastructure.Persistence;
+using PureFreshMilk.Infrastructure.Persistence.Repositories;
+
+namespace PureFreshMilk.Infrastructure.Tests;
+
+[Trait("Category", "Database")]
+public sealed class RepositoryRoundTripTests(PostgresDatabaseFixture database) : IClassFixture