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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2845fd3..d8efd5a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,7 +69,9 @@ jobs:
- run: dotnet format PureFreshMilk.slnx --verify-no-changes --no-restore
# Directory.Build.props sets TreatWarningsAsErrors, so warnings fail the build.
- run: dotnet build PureFreshMilk.slnx --configuration Release --no-restore
- - run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build
+ # Tests that need PostgreSQL are tagged Category=Database and run in the database job. The test
+ # project that holds only those is left with no tests, which ends with exit code 8 ("zero tests ran").
+ - run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build --filter-not-trait "Category=Database" --ignore-exit-code 8
database:
name: Database — Compose PostgreSQL + API readiness
@@ -92,24 +94,46 @@ jobs:
working-directory: backend
run: dotnet build src/PureFreshMilk.Api --configuration Release
- - name: Start the API and wait until /health/ready is healthy
+ - name: Apply the migrations to the empty database
+ working-directory: backend
+ env:
+ ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
+ run: |
+ dotnet tool restore
+ dotnet ef database update --project src/PureFreshMilk.Infrastructure --startup-project src/PureFreshMilk.Api --configuration Release --no-build
+
+ - name: Database tests (a throwaway database per test class, built from the migrations)
+ working-directory: backend
+ env:
+ ConnectionStrings__PureFreshMilkTests: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
+ run: dotnet test --project tests/PureFreshMilk.Infrastructure.Tests --configuration Release
+
+ - name: Start the API, wait until /health/ready is healthy, then smoke-test the data endpoints
working-directory: backend
env:
ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
ASPNETCORE_URLS: http://127.0.0.1:5080
+ # The data endpoints exist outside Development only when switched on, until authentication exists.
+ Access__AllowUnauthenticated: "true"
run: |
dotnet run --project src/PureFreshMilk.Api --configuration Release --no-build --no-launch-profile > api.log 2>&1 &
+ ready=false
+ # Ready also means every migration has been applied.
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:5080/health/ready; then
echo
echo "API is ready."
- exit 0
+ ready=true
+ break
fi
sleep 2
done
- echo "The API did not become ready within 60 seconds."
- cat api.log
- exit 1
+ if [ "$ready" != true ]; then
+ echo "The API did not become ready within 60 seconds."
+ cat api.log
+ exit 1
+ fi
+ bash ../scripts/smoke-api.sh http://127.0.0.1:5080 || { cat api.log; exit 1; }
- name: Stop PostgreSQL
if: always()
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 849a315..6fb7133 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,6 +1,7 @@
# Copy to .env.local (git-ignored) and adjust. Placeholders only — never commit real values.
# Base URL of the ASP.NET Core API, without a trailing slash or /api/v1.
-# Optional: the web app works without the API (the Today screen uses demo data).
+# Optional: the web app works without the API. Then Today and Recipients show labelled demo data, and
+# Settings says it needs the API. With an address, Recipients and Settings use the API's data.
# NEXT_PUBLIC_* values are inlined into the browser bundle at build time, so never put secrets here.
NEXT_PUBLIC_API_BASE_URL=http://localhost:5080
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index aa52498..791de77 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -43,6 +43,10 @@ export default defineConfig({
command: isCI
? `npm run start -- --port ${port}`
: `npm run build && npm run start -- --port ${port}`,
+ // These tests cover the app with no API address, where the Recipients and Settings screens say so
+ // and show labelled demo data. Pin it, so a developer's .env.local cannot change what is tested.
+ // (CI builds earlier, in a clean checkout.)
+ env: { NEXT_PUBLIC_API_BASE_URL: "" },
url: baseURL,
reuseExistingServer: !isCI,
timeout: 240_000,
diff --git a/apps/web/src/app/app/recipients/page.tsx b/apps/web/src/app/app/recipients/page.tsx
index 5c4328e..a440299 100644
--- a/apps/web/src/app/app/recipients/page.tsx
+++ b/apps/web/src/app/app/recipients/page.tsx
@@ -1,27 +1,8 @@
import type { Metadata } from "next";
-import { PageHeader } from "@/components/layout/page-header";
-import { DemoBadge } from "@/components/ui/badge";
-import { Notice } from "@/components/ui/notice";
-import { DemoRecipientList } from "@/features/recipients/demo-recipient-list";
-import { DEMO_RECIPIENTS } from "@/lib/demo-household";
+import { RecipientsScreen } from "@/features/recipients/recipients-screen";
export const metadata: Metadata = { title: "Recipients" };
export default function RecipientsPage() {
- return (
- <>
- }
- />
-
-
-
- These are the demo household's recipients. Managing your own arrives once data is
- saved.
-
-
- >
- );
+ return ;
}
diff --git a/apps/web/src/app/app/settings/page.tsx b/apps/web/src/app/app/settings/page.tsx
index 3d368ab..05842a3 100644
--- a/apps/web/src/app/app/settings/page.tsx
+++ b/apps/web/src/app/app/settings/page.tsx
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
-import { House } from "lucide-react";
+import { CalendarOff } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
import { PlaceholderPanel } from "@/components/ui/placeholder-panel";
import { ApiStatusCard } from "@/features/settings/api-status-card";
+import { HouseholdSettings } from "@/features/settings/household-settings";
export const metadata: Metadata = { title: "Settings" };
@@ -11,15 +12,12 @@ export default function SettingsPage() {
<>
diff --git a/apps/web/src/components/ui/select-field.tsx b/apps/web/src/components/ui/select-field.tsx
new file mode 100644
index 0000000..e5c9ce2
--- /dev/null
+++ b/apps/web/src/components/ui/select-field.tsx
@@ -0,0 +1,59 @@
+import type { ComponentProps } from "react";
+import { cn } from "@/lib/cn";
+
+interface SelectFieldProps extends Omit, "id"> {
+ id: string;
+ label: string;
+ hint?: string;
+ /** What is wrong with the value. Shown under the field and announced with it. */
+ error?: string;
+ options: readonly { value: string; label: string }[];
+}
+
+export function SelectField({
+ id,
+ label,
+ hint,
+ error,
+ options,
+ className,
+ ...selectProps
+}: SelectFieldProps) {
+ const hintId = hint ? `${id}-hint` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
+
+ return (
+
+
+ {label}
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/ui/text-field.tsx b/apps/web/src/components/ui/text-field.tsx
index 4e0e997..7a692ad 100644
--- a/apps/web/src/components/ui/text-field.tsx
+++ b/apps/web/src/components/ui/text-field.tsx
@@ -5,10 +5,13 @@ interface TextFieldProps extends Omit, "id"> {
id: string;
label: string;
hint?: string;
+ /** What is wrong with the value. Shown under the field and announced with it. */
+ error?: string;
}
-export function TextField({ id, label, hint, className, ...inputProps }: TextFieldProps) {
+export function TextField({ id, label, hint, error, className, ...inputProps }: TextFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
return (
@@ -17,9 +20,11 @@ export function TextField({ id, label, hint, className, ...inputProps }: TextFie
) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
);
}
diff --git a/apps/web/src/features/household/household-form.tsx b/apps/web/src/features/household/household-form.tsx
new file mode 100644
index 0000000..67cfeac
--- /dev/null
+++ b/apps/web/src/features/household/household-form.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { Button } from "@/components/ui/button";
+import { SelectField } from "@/components/ui/select-field";
+import { TextField } from "@/components/ui/text-field";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { DEFAULT_TIME_ZONE_ID, timeZoneOptions } from "@/lib/time-zones";
+import { fieldErrors } from "@/services/api-client";
+import { setUpHousehold } from "@/services/household-service";
+import type { ApiHousehold } from "@/types/api";
+
+/**
+ * Creates the household (first run) or changes its name and time zone. The household has to exist before
+ * anything else can be saved, because everything belongs to it.
+ */
+export function HouseholdForm({
+ household,
+ submitLabel,
+ onSaved,
+}: {
+ /** The existing household, or undefined when it is being created. */
+ household?: ApiHousehold;
+ submitLabel: string;
+ onSaved: (household: ApiHousehold) => void;
+}) {
+ const [name, setName] = useState(household?.name ?? "");
+ const [timeZoneId, setTimeZoneId] = useState(household?.timeZoneId ?? DEFAULT_TIME_ZONE_ID);
+ const [errors, setErrors] = useState>({});
+ const [formError, setFormError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setSaving(true);
+ setErrors({});
+ setFormError(null);
+
+ try {
+ onSaved(await setUpHousehold({ name, timeZoneId }));
+ } catch (error) {
+ const fields = fieldErrors(error);
+ if (Object.keys(fields).length > 0) setErrors(fields);
+ else setFormError(describeApiError(error));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/recipients/recipient-form.tsx b/apps/web/src/features/recipients/recipient-form.tsx
new file mode 100644
index 0000000..3f42f3b
--- /dev/null
+++ b/apps/web/src/features/recipients/recipient-form.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Button } from "@/components/ui/button";
+import { TextField } from "@/components/ui/text-field";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { formatQuantity } from "@/lib/quantity";
+import { fieldErrors, hasProblemCode } from "@/services/api-client";
+import { addRecipient, updateRecipient } from "@/services/recipient-service";
+import type { ApiRecipient } from "@/types/api";
+
+const WHOLE_NUMBER = /^\d+$/;
+
+/** Adds a recipient, or changes the name, usual quantity and start date of an existing one. */
+export function RecipientForm({
+ recipient,
+ today,
+ onSaved,
+ onCancel,
+}: {
+ /** The recipient being edited, or undefined when adding. */
+ recipient?: ApiRecipient;
+ /** The household's calendar date now, YYYY-MM-DD, from the server. */
+ today: string;
+ onSaved: () => void;
+ onCancel: () => void;
+}) {
+ const idPrefix = recipient ? `recipient-${recipient.id}` : "recipient-new";
+ const nameRef = useRef(null);
+
+ const [name, setName] = useState(recipient?.name ?? "");
+ const [expectedMl, setExpectedMl] = useState(recipient ? String(recipient.expectedMl) : "");
+ const [startsOn, setStartsOn] = useState(recipient?.startsOn ?? today);
+ const [errors, setErrors] = useState>({});
+ const [formError, setFormError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ // Move straight to the first field when the form opens.
+ useEffect(() => nameRef.current?.focus(), []);
+
+ const quantityHint = WHOLE_NUMBER.test(expectedMl.trim())
+ ? `That is ${formatQuantity(Number(expectedMl))} a day.`
+ : "In millilitres, for example 500 or 1000.";
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setFormError(null);
+
+ const problems: Record = {};
+ if (!WHOLE_NUMBER.test(expectedMl.trim())) {
+ problems.expectedMl = "Enter a whole number of millilitres, for example 500.";
+ }
+ if (startsOn === "") problems.startsOn = "Choose the first day a delivery is expected.";
+ if (Object.keys(problems).length > 0) {
+ setErrors(problems);
+ return;
+ }
+
+ setErrors({});
+ setSaving(true);
+ try {
+ const input = { name, expectedMl: Number(expectedMl.trim()), startsOn };
+ if (recipient) await updateRecipient(recipient.id, input);
+ else await addRecipient(input);
+ onSaved();
+ } catch (error) {
+ const fields = fieldErrors(error);
+ if (hasProblemCode(error, "recipient-name-taken")) {
+ setErrors({ name: "Another recipient is already called that. Use a different name." });
+ } else if (Object.keys(fields).length > 0) {
+ setErrors(fields);
+ } else {
+ setFormError(describeApiError(error));
+ }
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/recipients/recipient-row.tsx b/apps/web/src/features/recipients/recipient-row.tsx
new file mode 100644
index 0000000..3676e07
--- /dev/null
+++ b/apps/web/src/features/recipients/recipient-row.tsx
@@ -0,0 +1,77 @@
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { RecipientMarker } from "@/components/ui/recipient-marker";
+import { formatIsoDate } from "@/lib/dates";
+import { formatQuantity } from "@/lib/quantity";
+import { recipientMarker } from "@/lib/recipient-marker";
+import type { ApiRecipient } from "@/types/api";
+
+/** One recipient in the list: who, their usual quantity, when they start or stop, and what can be done. */
+export function RecipientRow({
+ recipient,
+ today,
+ busy,
+ onEdit,
+ onArchive,
+ onRestore,
+}: {
+ recipient: ApiRecipient;
+ /** The household's calendar date now, YYYY-MM-DD, from the server. */
+ today: string;
+ busy: boolean;
+ onEdit: () => void;
+ onArchive: () => void;
+ onRestore: () => void;
+}) {
+ // YYYY-MM-DD dates sort as text, so they can be compared without turning them into Date objects.
+ const archived = recipient.archivedFrom !== null && recipient.archivedFrom <= today;
+ const startsLater = recipient.startsOn > today;
+
+ return (
+
+
+
+
{recipient.name}
+
+ {formatQuantity(recipient.expectedMl)} a day
+ {startsLater ? ` · starts ${formatIsoDate(recipient.startsOn)}` : null}
+
+ {recipient.archivedFrom !== null ? (
+
+ {archived ? "Archived" : "Stops"} from {formatIsoDate(recipient.archivedFrom)}
+
+ ) : null}
+
+
+
+ Edit
+
+ {recipient.archivedFrom !== null ? (
+ // Archived: bring them back. Archiving scheduled for later: cancel it.
+
+ {archived ? "Restore" : "Keep"}
+
+ ) : (
+
+ Archive
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/recipients/recipients-screen.tsx b/apps/web/src/features/recipients/recipients-screen.tsx
new file mode 100644
index 0000000..52a1ef1
--- /dev/null
+++ b/apps/web/src/features/recipients/recipients-screen.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { LoaderCircle, Plus, UsersRound } from "lucide-react";
+import { PageHeader } from "@/components/layout/page-header";
+import { DemoBadge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Notice } from "@/components/ui/notice";
+import { HouseholdForm } from "@/features/household/household-form";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { useResource } from "@/features/shared/use-resource";
+import { DEMO_RECIPIENTS } from "@/lib/demo-household";
+import { getApiBaseUrl, hasProblemCode } from "@/services/api-client";
+import { getHousehold } from "@/services/household-service";
+import { archiveRecipient, listRecipients, unarchiveRecipient } from "@/services/recipient-service";
+import type { ApiRecipient } from "@/types/api";
+import { DemoRecipientList } from "./demo-recipient-list";
+import { RecipientForm } from "./recipient-form";
+import { RecipientRow } from "./recipient-row";
+
+const DESCRIPTION =
+ "Everyone in the household who gets a daily delivery, with their usual quantity.";
+
+/**
+ * The Recipients page. With no API address it shows the synthetic demo household, clearly labelled.
+ * With one, it works on the household's real recipients and never falls back to demo data silently.
+ */
+export function RecipientsScreen() {
+ return getApiBaseUrl() ? : ;
+}
+
+function DemoRecipients() {
+ return (
+ <>
+ } />
+
+
+
+ These are sample recipients and nothing here is saved. To manage your own, run the API and
+ point the web app at it (see docs/development.md).
+
+
+ >
+ );
+}
+
+/** What the page is showing besides the list: nothing, the add form, or a form or question for one recipient. */
+type Panel =
+ | { kind: "closed" }
+ | { kind: "add" }
+ | { kind: "edit"; id: string }
+ | { kind: "confirm-archive"; id: string };
+
+function ConnectedRecipients() {
+ const [includeArchived, setIncludeArchived] = useState(false);
+ const load = useCallback(
+ async (signal: AbortSignal) => {
+ const [household, recipients] = await Promise.all([
+ getHousehold({ signal }),
+ listRecipients({ includeArchived, signal }),
+ ]);
+ return { household, recipients };
+ },
+ [includeArchived],
+ );
+ const { state, refresh, retry } = useResource(load);
+
+ const [panel, setPanel] = useState({ kind: "closed" });
+ const [busyId, setBusyId] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ async function run(id: string, action: () => Promise) {
+ setBusyId(id);
+ setActionError(null);
+ try {
+ await action();
+ setPanel({ kind: "closed" });
+ refresh();
+ } catch (error) {
+ setActionError(describeApiError(error));
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ if (state.kind === "loading") {
+ return (
+ <>
+
+
+
+ Loading recipients…
+
+ >
+ );
+ }
+
+ if (state.kind === "failed") {
+ if (hasProblemCode(state.error, "household-not-set-up")) {
+ return (
+ <>
+
+
+ Set up your household first
+
+ Recipients, prices and deliveries all belong to a household. Name yours to begin.
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ Try again
+
+ }
+ >
+ {describeApiError(state.error)}
+
+ >
+ );
+ }
+
+ const { household, recipients } = state.data;
+ const editing = panel.kind === "edit" ? panel.id : null;
+ const confirming = panel.kind === "confirm-archive" ? panel.id : null;
+
+ return (
+ <>
+ setPanel({ kind: "add" })} disabled={panel.kind === "add"}>
+
+ Add recipient
+
+ }
+ />
+
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+ {panel.kind === "add" ? (
+
+ Add a recipient
+ {
+ setPanel({ kind: "closed" });
+ refresh();
+ }}
+ onCancel={() => setPanel({ kind: "closed" })}
+ />
+
+ ) : null}
+
+ {recipients.length === 0 ? (
+
setPanel({ kind: "add" })}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ setIncludeArchived(event.target.checked)}
+ />
+ Show archived recipients
+
+
+ >
+ );
+}
+
+function EmptyRecipients({ archivedShown, onAdd }: { archivedShown: boolean; onAdd: () => void }) {
+ return (
+
+
+
+
+ No recipients yet
+
+ {archivedShown
+ ? "Nobody has been added to this household."
+ : "Add each person who gets milk, with how much they usually get a day. Deliveries are recorded against this list."}
+
+
+
+ Add the first recipient
+
+
+ );
+}
+
+function ArchiveQuestion({
+ recipient,
+ busy,
+ onConfirm,
+ onCancel,
+}: {
+ recipient: ApiRecipient;
+ busy: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+
+
Archive {recipient.name}?
+
+ No more deliveries are expected from today, or from tomorrow if they only started today.
+ Their history is kept, and you can restore them later.
+
+
+
+ {busy ? "Archiving…" : `Archive ${recipient.name}`}
+
+
+ Keep
+
+
+
+ );
+}
diff --git a/apps/web/src/features/settings/household-card.tsx b/apps/web/src/features/settings/household-card.tsx
new file mode 100644
index 0000000..38b2a8b
--- /dev/null
+++ b/apps/web/src/features/settings/household-card.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useCallback } from "react";
+import { House, LoaderCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Notice } from "@/components/ui/notice";
+import { HouseholdForm } from "@/features/household/household-form";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { useResource } from "@/features/shared/use-resource";
+import { hasProblemCode } from "@/services/api-client";
+import { getHousehold } from "@/services/household-service";
+
+/** Settings: the household's name and time zone. Also where a brand-new household is created. */
+export function HouseholdCard() {
+ const load = useCallback((signal: AbortSignal) => getHousehold({ signal }), []);
+ const { state, refresh, retry } = useResource(load);
+
+ const needsSetUp = state.kind === "failed" && hasProblemCode(state.error, "household-not-set-up");
+
+ return (
+
+
+
+
+
+
+
Household
+
+ {needsSetUp
+ ? "Not set up yet. Everything else belongs to the household."
+ : "Its name, and the time zone that decides what “today” means."}
+
+
+
+
+
+ {state.kind === "loading" ? (
+
+
+ Loading the household…
+
+ ) : null}
+
+ {state.kind === "failed" && !needsSetUp ? (
+
+ Try again
+
+ }
+ >
+ {describeApiError(state.error)}
+
+ ) : null}
+
+ {needsSetUp ?
: null}
+
+ {state.kind === "ready" ? (
+ // Keyed by the saved values, so the form starts again from what the server now holds.
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/features/settings/household-settings.tsx b/apps/web/src/features/settings/household-settings.tsx
new file mode 100644
index 0000000..c2a8c53
--- /dev/null
+++ b/apps/web/src/features/settings/household-settings.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { Notice } from "@/components/ui/notice";
+import { getApiBaseUrl } from "@/services/api-client";
+import { HouseholdCard } from "./household-card";
+import { MilkRateCard } from "./milk-rate-card";
+
+/**
+ * The settings that live in the API: household and milk price. Without an API address there is nowhere to
+ * save them, so say so instead of showing forms that cannot work.
+ */
+export function HouseholdSettings() {
+ if (!getApiBaseUrl()) {
+ return (
+
+ This app is not connected to an API, so there is nowhere to save them. Run the API and point
+ the web app at it (see docs/development.md).
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/src/features/settings/milk-rate-card.tsx b/apps/web/src/features/settings/milk-rate-card.tsx
new file mode 100644
index 0000000..7c05842
--- /dev/null
+++ b/apps/web/src/features/settings/milk-rate-card.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { useCallback, useState, type FormEvent } from "react";
+import { IndianRupee, LoaderCircle } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Notice } from "@/components/ui/notice";
+import { TextField } from "@/components/ui/text-field";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { useResource } from "@/features/shared/use-resource";
+import { formatIsoDate } from "@/lib/dates";
+import { formatPaise, formatPricePerLitre, parseRupees } from "@/lib/money";
+import { fieldErrors, hasProblemCode } from "@/services/api-client";
+import { getHousehold } from "@/services/household-service";
+import {
+ addMilkRate,
+ deleteMilkRate,
+ getMilkRateInEffect,
+ listMilkRates,
+} from "@/services/milk-rate-service";
+import type { ApiMilkRate } from "@/types/api";
+
+/**
+ * Settings: the price of a litre of milk, which changes over time. A new price has a first day; the days
+ * before it keep the price they had. Which price is in force is decided by the server.
+ */
+export function MilkRateCard() {
+ const load = useCallback(async (signal: AbortSignal) => {
+ const [household, rates, inEffect] = await Promise.all([
+ getHousehold({ signal }),
+ listMilkRates({ signal }),
+ getMilkRateInEffect(undefined, { signal }),
+ ]);
+ return { household, rates, inEffect };
+ }, []);
+ const { state, refresh, retry } = useResource(load);
+
+ const needsHousehold =
+ state.kind === "failed" && hasProblemCode(state.error, "household-not-set-up");
+
+ return (
+
+
+
+
+
+
+
Milk price
+
+ What one litre costs. When the price changes, add the new price and the day it starts.
+
+
+
+
+
+ {state.kind === "loading" ? (
+
+
+ Loading prices…
+
+ ) : null}
+
+ {needsHousehold ? (
+
+ The price belongs to the household. Create it in the Household section above.
+
+ ) : null}
+
+ {state.kind === "failed" && !needsHousehold ? (
+
+ Try again
+
+ }
+ >
+ {describeApiError(state.error)}
+
+ ) : null}
+
+ {state.kind === "ready" ? (
+
+ ) : null}
+
+
+ );
+}
+
+function RatesPanel({
+ today,
+ rates,
+ inEffect,
+ onChanged,
+}: {
+ today: string;
+ rates: readonly ApiMilkRate[];
+ inEffect: ApiMilkRate | null;
+ onChanged: () => void;
+}) {
+ const [confirmingId, setConfirmingId] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+ const [removeError, setRemoveError] = useState(null);
+
+ async function remove(rate: ApiMilkRate) {
+ setBusyId(rate.id);
+ setRemoveError(null);
+ try {
+ await deleteMilkRate(rate.id);
+ setConfirmingId(null);
+ onChanged();
+ } catch (error) {
+ setRemoveError(describeApiError(error));
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ return (
+
+
+ {inEffect ? (
+
+
+ {formatPricePerLitre(inEffect.pricePerLitrePaise)}
+
+
+ since {formatIsoDate(inEffect.effectiveFrom)}
+
+
+ ) : (
+
+ No price is in force yet. Days before the first price have no price, and are never
+ counted as free.
+
+ )}
+
+
+ {removeError ? (
+
+ {removeError}
+
+ ) : null}
+
+ {rates.length > 0 ? (
+
+
Price history
+
+ {rates.map((rate) => (
+
+
+
+
+ {formatPricePerLitre(rate.pricePerLitrePaise)}
+
+
+ from {formatIsoDate(rate.effectiveFrom)}
+
+
+ {inEffect?.id === rate.id ?
In effect : null}
+
setConfirmingId(rate.id)}
+ disabled={busyId === rate.id}
+ aria-label={`Remove the price from ${formatIsoDate(rate.effectiveFrom)}`}
+ >
+ Remove
+
+
+ {confirmingId === rate.id ? (
+
+
+ Remove {formatPaise(rate.pricePerLitrePaise)} from{" "}
+ {formatIsoDate(rate.effectiveFrom)}?
+
+
+ The days it covered use the earlier price, or have none if there is no earlier
+ one.
+
+
+ remove(rate)} disabled={busyId === rate.id}>
+ {busyId === rate.id ? "Removing…" : "Remove price"}
+
+ setConfirmingId(null)}
+ disabled={busyId === rate.id}
+ >
+ Keep
+
+
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function AddRateForm({ today, onAdded }: { today: string; onAdded: () => void }) {
+ const [price, setPrice] = useState("");
+ const [effectiveFrom, setEffectiveFrom] = useState(today);
+ const [errors, setErrors] = useState>({});
+ const [formError, setFormError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setFormError(null);
+
+ const parsed = parseRupees(price);
+ const problems: Record = {};
+ if (!parsed.ok) problems.pricePerLitrePaise = parsed.message;
+ if (effectiveFrom === "") problems.effectiveFrom = "Choose the first day this price applies.";
+ if (!parsed.ok || Object.keys(problems).length > 0) {
+ setErrors(problems);
+ return;
+ }
+
+ setErrors({});
+ setSaving(true);
+ try {
+ await addMilkRate({ effectiveFrom, pricePerLitrePaise: parsed.paise });
+ setPrice("");
+ setEffectiveFrom(today);
+ onAdded();
+ } catch (error) {
+ const fields = fieldErrors(error);
+ if (hasProblemCode(error, "rate-date-taken")) {
+ setErrors({
+ effectiveFrom:
+ "A price already starts on that day. Remove it first, or choose another day.",
+ });
+ } else if (Object.keys(fields).length > 0) {
+ setErrors(fields);
+ } else {
+ setFormError(describeApiError(error));
+ }
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/shared/api-error-message.ts b/apps/web/src/features/shared/api-error-message.ts
new file mode 100644
index 0000000..de73439
--- /dev/null
+++ b/apps/web/src/features/shared/api-error-message.ts
@@ -0,0 +1,25 @@
+import { ApiError, getApiBaseUrl } from "@/services/api-client";
+
+/** A sentence a person can act on, for any failure of an API call. */
+export function describeApiError(error: unknown): string {
+ if (error instanceof ApiError) {
+ switch (error.kind) {
+ case "not-configured":
+ return "No API address is set. Add NEXT_PUBLIC_API_BASE_URL to apps/web/.env.local, then restart the dev server.";
+ case "unreachable":
+ return `Can't reach the API at ${getApiBaseUrl()}. Start it (see docs/development.md), then try again.`;
+ case "http":
+ return (
+ error.problem?.detail ??
+ firstValidationMessage(error) ??
+ `The API answered with HTTP ${error.status}. Check the API logs.`
+ );
+ }
+ }
+ return "Something went wrong. Check the browser console for details.";
+}
+
+/** A rejected request names the field it is about; when no form field can show that, say it in words. */
+function firstValidationMessage(error: ApiError): string | undefined {
+ return Object.values(error.problem?.errors ?? {})[0]?.[0];
+}
diff --git a/apps/web/src/features/shared/use-resource.ts b/apps/web/src/features/shared/use-resource.ts
new file mode 100644
index 0000000..85e0d03
--- /dev/null
+++ b/apps/web/src/features/shared/use-resource.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+export type Resource =
+ { kind: "loading" } | { kind: "ready"; data: T } | { kind: "failed"; error: unknown };
+
+/**
+ * Loads something from the API and keeps it in step with what the screen does to it.
+ *
+ * `load` must be stable (wrap it in useCallback with the values it uses): a new function starts a new load.
+ * - `refresh()` fetches again but keeps showing the current data, for use after a change was saved.
+ * - `retry()` goes back to the loading state first, for use after a failure.
+ */
+export function useResource(load: (signal: AbortSignal) => Promise) {
+ const [state, setState] = useState>({ kind: "loading" });
+ const [attempt, setAttempt] = useState(0);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ load(controller.signal)
+ .then((data) => {
+ if (!controller.signal.aborted) setState({ kind: "ready", data });
+ })
+ .catch((error: unknown) => {
+ if (!controller.signal.aborted) setState({ kind: "failed", error });
+ });
+
+ return () => controller.abort();
+ }, [load, attempt]);
+
+ const refresh = useCallback(() => setAttempt((value) => value + 1), []);
+ const retry = useCallback(() => {
+ setState({ kind: "loading" });
+ setAttempt((value) => value + 1);
+ }, []);
+
+ return { state, refresh, retry };
+}
diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts
index 85177a7..c728d76 100644
--- a/apps/web/src/lib/dates.ts
+++ b/apps/web/src/lib/dates.ts
@@ -6,3 +6,41 @@ export function formatLongDate(date: Date, locale?: string): string {
month: "long",
}).format(date);
}
+
+/*
+ * Calendar dates travel as "YYYY-MM-DD" strings and are never turned into a Date with the local time
+ * zone: new Date("2026-10-30") is midnight UTC, which is the previous day in some zones. The household's
+ * calendar date is decided by the server, so these helpers only read and display it.
+ */
+
+const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
+
+/** Whether `value` is a real calendar date written as YYYY-MM-DD. */
+export function isIsoDate(value: string): boolean {
+ return toUtcDate(value) !== null;
+}
+
+/** "2026-10-30" → "30 Oct 2026" in the viewer's locale (or the given one). Anything else is returned as it is. */
+export function formatIsoDate(value: string, locale?: string): string {
+ const date = toUtcDate(value);
+ if (!date) return value;
+
+ return new Intl.DateTimeFormat(locale, {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ }).format(date);
+}
+
+function toUtcDate(value: string): Date | null {
+ const match = ISO_DATE.exec(value);
+ if (!match) return null;
+
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
+ const date = new Date(Date.UTC(year, month - 1, day));
+
+ const isRealDate =
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
+ return isRealDate ? date : null;
+}
diff --git a/apps/web/src/lib/money.ts b/apps/web/src/lib/money.ts
new file mode 100644
index 0000000..f471f3f
--- /dev/null
+++ b/apps/web/src/lib/money.ts
@@ -0,0 +1,52 @@
+/**
+ * Display and input helpers for prices. Money is whole paise everywhere, matching PureFreshMilk.Domain's
+ * Money and PricePerLitre (ADR 0004). These helpers format and parse text; they never do billing
+ * arithmetic, which the server owns.
+ */
+
+export const MIN_PRICE_PAISE = 1;
+export const MAX_PRICE_PAISE = 1_000_000;
+
+const rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" });
+
+/** 6250 → "₹62.50". */
+export function formatPaise(paise: number): string {
+ return rupees.format(paise / 100);
+}
+
+/** 6250 → "₹62.50 a litre". */
+export function formatPricePerLitre(paisePerLitre: number): string {
+ return `${formatPaise(paisePerLitre)} a litre`;
+}
+
+export type ParsedPrice = { ok: true; paise: number } | { ok: false; message: string };
+
+const PRICE_PATTERN = /^(\d{1,5})(?:\.(\d{1,2}))?$/;
+
+/**
+ * Reads what a person types for the price of a litre in rupees, for example "62.50", "62.5", "₹62" or
+ * "62". Only digits and up to two decimals are accepted, and the text is turned into paise without
+ * floating-point arithmetic.
+ */
+export function parseRupees(input: string): ParsedPrice {
+ const text = input.replace(/^\s*₹/, "").trim();
+
+ if (text === "") {
+ return { ok: false, message: "Enter the price of one litre, for example 62.50." };
+ }
+
+ const match = PRICE_PATTERN.exec(text);
+ if (!match) {
+ return { ok: false, message: "Use numbers only, with up to two decimals, for example 62.50." };
+ }
+
+ const paise = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
+ if (paise < MIN_PRICE_PAISE || paise > MAX_PRICE_PAISE) {
+ return {
+ ok: false,
+ message: `Enter a price between ${formatPaise(MIN_PRICE_PAISE)} and ${formatPaise(MAX_PRICE_PAISE)} a litre.`,
+ };
+ }
+
+ return { ok: true, paise };
+}
diff --git a/apps/web/src/lib/time-zones.ts b/apps/web/src/lib/time-zones.ts
new file mode 100644
index 0000000..bf9bd1a
--- /dev/null
+++ b/apps/web/src/lib/time-zones.ts
@@ -0,0 +1,24 @@
+/**
+ * Time zones offered when setting up a household. The API accepts any IANA id; this short list covers the
+ * likely cases, and a household already on another zone keeps it (see timeZoneOptions).
+ */
+export const DEFAULT_TIME_ZONE_ID = "Asia/Kolkata";
+
+const COMMON_TIME_ZONES: readonly { id: string; label: string }[] = [
+ { id: "Asia/Kolkata", label: "India (Asia/Kolkata)" },
+ { id: "Asia/Dubai", label: "United Arab Emirates (Asia/Dubai)" },
+ { id: "Asia/Singapore", label: "Singapore (Asia/Singapore)" },
+ { id: "Europe/London", label: "United Kingdom (Europe/London)" },
+ { id: "America/New_York", label: "US Eastern (America/New_York)" },
+ { id: "America/Los_Angeles", label: "US Pacific (America/Los_Angeles)" },
+ { id: "Australia/Sydney", label: "Sydney (Australia/Sydney)" },
+ { id: "UTC", label: "UTC" },
+];
+
+/** The common zones, plus `currentId` when the household is on a zone that is not in the list. */
+export function timeZoneOptions(currentId?: string): readonly { id: string; label: string }[] {
+ if (!currentId || COMMON_TIME_ZONES.some((zone) => zone.id === currentId)) {
+ return COMMON_TIME_ZONES;
+ }
+ return [{ id: currentId, label: currentId }, ...COMMON_TIME_ZONES];
+}
diff --git a/apps/web/src/services/api-client.ts b/apps/web/src/services/api-client.ts
index a9b76f4..9e1d6a1 100644
--- a/apps/web/src/services/api-client.ts
+++ b/apps/web/src/services/api-client.ts
@@ -3,21 +3,48 @@
* Components never call fetch directly; they go through a service module.
*/
+import type { ApiProblem } from "@/types/api";
+
const API_PREFIX = "/api/v1";
const DEFAULT_TIMEOUT_MS = 5000;
export type ApiErrorKind = "not-configured" | "unreachable" | "http";
+interface ApiErrorOptions extends ErrorOptions {
+ /** The Problem Details body of an error response, when the API sent one. */
+ problem?: ApiProblem;
+}
+
export class ApiError extends Error {
+ readonly problem?: ApiProblem;
+
constructor(
message: string,
readonly kind: ApiErrorKind,
readonly status?: number,
- options?: ErrorOptions,
+ options?: ApiErrorOptions,
) {
super(message, options);
this.name = "ApiError";
+ this.problem = options?.problem;
+ }
+}
+
+/** Whether `error` is an API error answered with the given stable `code`, for example "recipient-name-taken". */
+export function hasProblemCode(error: unknown, code: string): error is ApiError {
+ return error instanceof ApiError && error.problem?.code === code;
+}
+
+/** The first validation message for each input field of a 400 response, keyed by the API's field name. */
+export function fieldErrors(error: unknown): Record {
+ if (!(error instanceof ApiError) || !error.problem?.errors) return {};
+
+ const messages: Record = {};
+ for (const [field, list] of Object.entries(error.problem.errors)) {
+ const first = list[0];
+ if (first) messages[field] = first;
}
+ return messages;
}
/** Base URL from NEXT_PUBLIC_API_BASE_URL without a trailing slash, or null when unset. */
@@ -31,7 +58,37 @@ export interface ApiRequestOptions {
timeoutMs?: number;
}
-export async function apiGet(path: string, options: ApiRequestOptions = {}): Promise {
+export function apiGet(path: string, options: ApiRequestOptions = {}): Promise {
+ return request("GET", path, undefined, options);
+}
+
+/** POST, with an optional JSON body. */
+export function apiPost(
+ path: string,
+ body?: unknown,
+ options: ApiRequestOptions = {},
+): Promise {
+ return request("POST", path, body, options);
+}
+
+export function apiPut(
+ path: string,
+ body: unknown,
+ options: ApiRequestOptions = {},
+): Promise {
+ return request("PUT", path, body, options);
+}
+
+export async function apiDelete(path: string, options: ApiRequestOptions = {}): Promise {
+ await request("DELETE", path, undefined, options);
+}
+
+async function request(
+ method: string,
+ path: string,
+ body: unknown,
+ options: ApiRequestOptions,
+): Promise {
const baseUrl = getApiBaseUrl();
if (!baseUrl) {
throw new ApiError("NEXT_PUBLIC_API_BASE_URL is not set.", "not-configured");
@@ -40,12 +97,16 @@ export async function apiGet(path: string, options: ApiRequestOptions = {}):
const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
+ const headers: Record = { Accept: "application/json" };
+ const init: RequestInit = { method, headers, signal };
+ if (body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ init.body = JSON.stringify(body);
+ }
+
let response: Response;
try {
- response = await fetch(`${baseUrl}${API_PREFIX}${path}`, {
- headers: { Accept: "application/json" },
- signal,
- });
+ response = await fetch(`${baseUrl}${API_PREFIX}${path}`, init);
} catch (error) {
throw new ApiError(`Could not reach the API at ${baseUrl}.`, "unreachable", undefined, {
cause: error,
@@ -53,8 +114,21 @@ export async function apiGet(path: string, options: ApiRequestOptions = {}):
}
if (!response.ok) {
- throw new ApiError(`The API responded with HTTP ${response.status}.`, "http", response.status);
+ throw new ApiError(`The API responded with HTTP ${response.status}.`, "http", response.status, {
+ problem: await readProblem(response),
+ });
}
+ if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
+
+async function readProblem(response: Response): Promise {
+ if (!response.headers.get("content-type")?.includes("json")) return undefined;
+
+ try {
+ return (await response.json()) as ApiProblem;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/web/src/services/household-service.ts b/apps/web/src/services/household-service.ts
new file mode 100644
index 0000000..5987657
--- /dev/null
+++ b/apps/web/src/services/household-service.ts
@@ -0,0 +1,21 @@
+import type { ApiHousehold } from "@/types/api";
+import { apiGet, apiPut, type ApiRequestOptions } from "./api-client";
+
+/** Rejects with a "household-not-set-up" problem (HTTP 404) until the household has been set up. */
+export function getHousehold(options?: ApiRequestOptions): Promise {
+ return apiGet("/household", options);
+}
+
+export interface HouseholdInput {
+ name: string;
+ /** An IANA id. Left out, the API keeps the current one (Asia/Kolkata for a new household). */
+ timeZoneId?: string;
+}
+
+/** Creates the household the first time and updates it afterwards. */
+export function setUpHousehold(
+ input: HouseholdInput,
+ options?: ApiRequestOptions,
+): Promise {
+ return apiPut("/household", input, options);
+}
diff --git a/apps/web/src/services/milk-rate-service.ts b/apps/web/src/services/milk-rate-service.ts
new file mode 100644
index 0000000..4c72024
--- /dev/null
+++ b/apps/web/src/services/milk-rate-service.ts
@@ -0,0 +1,44 @@
+import type { ApiItems, ApiMilkRate } from "@/types/api";
+import { apiDelete, apiGet, apiPost, hasProblemCode, type ApiRequestOptions } from "./api-client";
+
+/** Every rate of the household, newest effective date first. */
+export async function listMilkRates(options?: ApiRequestOptions): Promise {
+ const result = await apiGet>("/milk-rates", options);
+ return result.items;
+}
+
+/**
+ * The rate in force on `on` (YYYY-MM-DD; default: today in the household's time zone), decided by the server.
+ * `null` means no price has started yet: that is not the same as a price of zero.
+ */
+export async function getMilkRateInEffect(
+ on?: string,
+ options?: ApiRequestOptions,
+): Promise {
+ const query = on ? `?on=${encodeURIComponent(on)}` : "";
+
+ try {
+ return await apiGet(`/milk-rates/in-effect${query}`, options);
+ } catch (error) {
+ if (hasProblemCode(error, "no-rate-in-effect")) return null;
+ throw error;
+ }
+}
+
+export interface MilkRateInput {
+ /** YYYY-MM-DD, the first day the price applies. It may be in the past. */
+ effectiveFrom: string;
+ /** The price of one litre, in whole paise. */
+ pricePerLitrePaise: number;
+}
+
+export function addMilkRate(
+ input: MilkRateInput,
+ options?: ApiRequestOptions,
+): Promise {
+ return apiPost("/milk-rates", input, options);
+}
+
+export function deleteMilkRate(id: string, options?: ApiRequestOptions): Promise {
+ return apiDelete(`/milk-rates/${encodeURIComponent(id)}`, options);
+}
diff --git a/apps/web/src/services/recipient-service.ts b/apps/web/src/services/recipient-service.ts
new file mode 100644
index 0000000..afccb33
--- /dev/null
+++ b/apps/web/src/services/recipient-service.ts
@@ -0,0 +1,61 @@
+import type { ApiItems, ApiRecipient } from "@/types/api";
+import { apiGet, apiPost, apiPut, type ApiRequestOptions } from "./api-client";
+
+export interface ListRecipientsOptions extends ApiRequestOptions {
+ /** Also return recipients who have been archived. */
+ includeArchived?: boolean;
+}
+
+/** The household's recipients, ordered by name by the server. */
+export async function listRecipients({
+ includeArchived = false,
+ ...options
+}: ListRecipientsOptions = {}): Promise {
+ const query = includeArchived ? "?includeArchived=true" : "";
+ const result = await apiGet>(`/recipients${query}`, options);
+ return result.items;
+}
+
+export interface RecipientInput {
+ name: string;
+ /** Usual daily quantity, in whole millilitres. */
+ expectedMl: number;
+ /** YYYY-MM-DD, the first day a delivery is expected. */
+ startsOn: string;
+}
+
+export function addRecipient(
+ input: RecipientInput,
+ options?: ApiRequestOptions,
+): Promise {
+ return apiPost("/recipients", input, options);
+}
+
+export function updateRecipient(
+ id: string,
+ input: RecipientInput,
+ options?: ApiRequestOptions,
+): Promise {
+ return apiPut(`/recipients/${encodeURIComponent(id)}`, input, options);
+}
+
+/** Stops deliveries from `archivedFrom` (YYYY-MM-DD; default: today). The recipient's history is kept. */
+export function archiveRecipient(
+ id: string,
+ archivedFrom?: string,
+ options?: ApiRequestOptions,
+): Promise {
+ return apiPost(
+ `/recipients/${encodeURIComponent(id)}/archive`,
+ archivedFrom ? { archivedFrom } : undefined,
+ options,
+ );
+}
+
+export function unarchiveRecipient(id: string, options?: ApiRequestOptions): Promise {
+ return apiPost(
+ `/recipients/${encodeURIComponent(id)}/unarchive`,
+ undefined,
+ options,
+ );
+}
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index 5e39392..8537a69 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -3,6 +3,8 @@
* keep them in sync by hand (there is deliberately no shared .NET/TypeScript package).
*/
+import type { Recipient } from "@/types/household";
+
/** GET /api/v1/status — PureFreshMilk.Api.Features.Status.StatusResponse */
export interface ApiStatusResponse {
service: string;
@@ -10,3 +12,49 @@ export interface ApiStatusResponse {
/** ISO 8601 timestamp. */
serverTime: string;
}
+
+/**
+ * An error body: RFC 9457 Problem Details plus the `code` extension.
+ * Every error the API raises on purpose has one; a 500 may not.
+ */
+export interface ApiProblem {
+ status?: number;
+ title?: string;
+ detail?: string;
+ /** Stable, machine-readable, for example "recipient-name-taken" or "household-not-set-up". */
+ code?: string;
+ /** Present on validation errors (400): input field name → messages. */
+ errors?: Record;
+}
+
+/** Every list endpoint answers with an object holding an `items` array. */
+export interface ApiItems {
+ items: T[];
+}
+
+/** GET and PUT /api/v1/household — PureFreshMilk.Api.Features.Households.HouseholdResponse */
+export interface ApiHousehold {
+ id: string;
+ name: string;
+ /** An IANA time zone id, for example "Asia/Kolkata". */
+ timeZoneId: string;
+ /** The calendar date now, in the household's time zone, as YYYY-MM-DD. The server decides it. */
+ today: string;
+}
+
+/** /api/v1/recipients — PureFreshMilk.Api.Features.Recipients.RecipientResponse */
+export interface ApiRecipient extends Recipient {
+ /** First day a delivery is expected, YYYY-MM-DD. */
+ startsOn: string;
+ /** First day without deliveries, YYYY-MM-DD, or null while the recipient is not archived. */
+ archivedFrom: string | null;
+}
+
+/** /api/v1/milk-rates — PureFreshMilk.Api.Features.MilkRates.MilkRateResponse */
+export interface ApiMilkRate {
+ id: string;
+ /** First day this price applies, YYYY-MM-DD. */
+ effectiveFrom: string;
+ /** The price of one litre, in whole paise. */
+ pricePerLitrePaise: number;
+}
diff --git a/apps/web/tests/e2e/recipients.spec.ts b/apps/web/tests/e2e/recipients.spec.ts
new file mode 100644
index 0000000..90812f5
--- /dev/null
+++ b/apps/web/tests/e2e/recipients.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test } from "@playwright/test";
+
+// These run against a build with no API address (see playwright.config.ts). The screens that talk to the
+// API are covered by the Vitest tests with mocked service calls and by the API's own tests.
+
+test.describe("Recipients with no API address", () => {
+ test("shows sample recipients, clearly labelled as demo data", async ({ page }) => {
+ await page.goto("/app/recipients");
+
+ await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible();
+ await expect(page.getByText("Demo data", { exact: true })).toBeVisible();
+ for (const name of ["Recipient A", "Recipient B", "Recipient C"]) {
+ await expect(page.getByText(name, { exact: true })).toBeVisible();
+ }
+ await expect(page.getByText("Demo data: this app is not connected to an API")).toBeVisible();
+ });
+
+ test("offers no way to add or edit demo recipients", async ({ page }) => {
+ await page.goto("/app/recipients");
+ await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible();
+
+ await expect(page.getByRole("button", { name: /add .*recipient/i })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /^edit /i })).toHaveCount(0);
+ await expect(page.getByRole("checkbox")).toHaveCount(0);
+ });
+
+ test("can be reached from the navigation", async ({ page }, testInfo) => {
+ await page.goto("/app/today");
+ const link =
+ testInfo.project.name === "mobile-360"
+ ? page
+ .getByRole("navigation", { name: "Household" })
+ .getByRole("link", { name: "Recipients" })
+ : page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "Recipients" });
+
+ await link.click();
+
+ await expect(page).toHaveURL(/\/app\/recipients$/);
+ await expect(page.getByRole("heading", { level: 1, name: "Recipients" })).toBeVisible();
+ });
+});
+
+test.describe("Settings with no API address", () => {
+ test("says the household and price settings need the API", async ({ page }) => {
+ await page.goto("/app/settings");
+
+ await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible();
+ await expect(page.getByText("Household and price settings need the API")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Add price" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Save household" })).toHaveCount(0);
+ });
+
+ test("still shows the API connection check and what is not built yet", async ({ page }) => {
+ await page.goto("/app/settings");
+
+ await expect(page.getByRole("heading", { name: "API connection" })).toBeVisible();
+ await expect(page.getByText(/No API address is set/)).toBeVisible();
+ await expect(page.getByText("Delivery days and pauses aren't built yet")).toBeVisible();
+ });
+});
+
+test("the demo screens that are not built on real data say so", async ({ page }) => {
+ await page.goto("/app/today");
+ await expect(page.getByText("Demo data", { exact: true })).toBeVisible();
+ await expect(page.getByText(/Sample household/)).toBeVisible();
+
+ await page.goto("/app/history");
+ await expect(page.getByText("History isn't built yet")).toBeVisible();
+
+ await page.goto("/app/month");
+ await expect(page.getByText("The monthly view isn't built yet")).toBeVisible();
+});
diff --git a/apps/web/tests/unit/features/api-error-message.test.ts b/apps/web/tests/unit/features/api-error-message.test.ts
new file mode 100644
index 0000000..4e7a1c5
--- /dev/null
+++ b/apps/web/tests/unit/features/api-error-message.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { describeApiError } from "@/features/shared/api-error-message";
+import { ApiError } from "@/services/api-client";
+
+beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"));
+afterEach(() => vi.unstubAllEnvs());
+
+describe("describeApiError", () => {
+ it("tells you how to set the API address when there is none", () => {
+ expect(describeApiError(new ApiError("x", "not-configured"))).toMatch(
+ /NEXT_PUBLIC_API_BASE_URL/,
+ );
+ });
+
+ it("names the address it could not reach", () => {
+ expect(describeApiError(new ApiError("x", "unreachable"))).toBe(
+ "Can't reach the API at http://localhost:5080. Start it (see docs/development.md), then try again.",
+ );
+ });
+
+ it("uses the API's own explanation when it gave one", () => {
+ const error = new ApiError("x", "http", 409, {
+ problem: { status: 409, detail: "The recipient is already archived." },
+ });
+
+ expect(describeApiError(error)).toBe("The recipient is already archived.");
+ });
+
+ it("falls back to the first validation message when there is no explanation", () => {
+ const error = new ApiError("x", "http", 400, {
+ problem: {
+ status: 400,
+ errors: { archivedFrom: ["A recipient must be active for at least one day."] },
+ },
+ });
+
+ expect(describeApiError(error)).toBe("A recipient must be active for at least one day.");
+ });
+
+ it("falls back to the status when the API said nothing useful", () => {
+ expect(describeApiError(new ApiError("x", "http", 500))).toBe(
+ "The API answered with HTTP 500. Check the API logs.",
+ );
+ });
+
+ it("copes with errors that are not API errors", () => {
+ expect(describeApiError(new Error("boom"))).toMatch(/Something went wrong/);
+ });
+});
diff --git a/apps/web/tests/unit/features/household-settings.test.tsx b/apps/web/tests/unit/features/household-settings.test.tsx
new file mode 100644
index 0000000..868af65
--- /dev/null
+++ b/apps/web/tests/unit/features/household-settings.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { HouseholdSettings } from "@/features/settings/household-settings";
+import { ApiError } from "@/services/api-client";
+import { getHousehold, setUpHousehold } from "@/services/household-service";
+import { getMilkRateInEffect, listMilkRates } from "@/services/milk-rate-service";
+import type { ApiHousehold } from "@/types/api";
+
+vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() }));
+vi.mock("@/services/milk-rate-service", () => ({
+ listMilkRates: vi.fn(),
+ getMilkRateInEffect: vi.fn(),
+ addMilkRate: vi.fn(),
+ deleteMilkRate: vi.fn(),
+}));
+
+const household: ApiHousehold = {
+ id: "h1",
+ name: "Example household",
+ timeZoneId: "Asia/Kolkata",
+ today: "2026-10-30",
+};
+
+beforeEach(() => {
+ vi.mocked(getHousehold).mockResolvedValue(household);
+ vi.mocked(listMilkRates).mockResolvedValue([]);
+ vi.mocked(getMilkRateInEffect).mockResolvedValue(null);
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetAllMocks();
+});
+
+describe("HouseholdSettings without an API address", () => {
+ it("says the settings need the API instead of showing forms that cannot work", () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
+
+ render( );
+
+ expect(screen.getByText("Household and price settings need the API")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save household" })).not.toBeInTheDocument();
+ expect(getHousehold).not.toHaveBeenCalled();
+ });
+});
+
+describe("HouseholdSettings with an API", () => {
+ beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"));
+
+ it("shows the household's name and time zone, ready to change", async () => {
+ render( );
+
+ expect(await screen.findByLabelText("Household name")).toHaveValue("Example household");
+ expect(screen.getByLabelText("Time zone")).toHaveValue("Asia/Kolkata");
+ expect(screen.getByRole("heading", { name: "Milk price" })).toBeInTheDocument();
+ });
+
+ it("saves a new name and time zone", async () => {
+ vi.mocked(setUpHousehold).mockResolvedValue({
+ ...household,
+ name: "Renamed",
+ timeZoneId: "Europe/London",
+ });
+ const user = userEvent.setup();
+
+ render( );
+ const name = await screen.findByLabelText("Household name");
+ await user.clear(name);
+ await user.type(name, "Renamed");
+ await user.selectOptions(screen.getByLabelText("Time zone"), "Europe/London");
+ await user.click(screen.getByRole("button", { name: "Save household" }));
+
+ expect(setUpHousehold).toHaveBeenCalledWith({ name: "Renamed", timeZoneId: "Europe/London" });
+ // The household card loaded again to show what was saved; the price card loaded once, at the start.
+ expect(getHousehold).toHaveBeenCalledTimes(3);
+ });
+
+ it("keeps a time zone that is not in the short list", async () => {
+ vi.mocked(getHousehold).mockResolvedValue({ ...household, timeZoneId: "Africa/Nairobi" });
+
+ render( );
+
+ expect(await screen.findByLabelText("Time zone")).toHaveValue("Africa/Nairobi");
+ });
+
+ it("offers to create the household when there is none", async () => {
+ vi.mocked(getHousehold).mockRejectedValue(
+ new ApiError("missing", "http", 404, {
+ problem: { status: 404, code: "household-not-set-up" },
+ }),
+ );
+ vi.mocked(listMilkRates).mockRejectedValue(
+ new ApiError("missing", "http", 404, {
+ problem: { status: 404, code: "household-not-set-up" },
+ }),
+ );
+
+ render( );
+
+ expect(await screen.findByText(/Not set up yet/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Create household" })).toBeInTheDocument();
+ expect(screen.getByText("Set up your household first")).toBeInTheDocument();
+ });
+
+ it("shows the API's message under the field it is about", async () => {
+ vi.mocked(setUpHousehold).mockRejectedValue(
+ new ApiError("bad", "http", 400, {
+ problem: {
+ status: 400,
+ code: "validation-failed",
+ errors: { name: ["The household name is required."] },
+ },
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ const name = await screen.findByLabelText("Household name");
+ await user.clear(name);
+ await user.click(screen.getByRole("button", { name: "Save household" }));
+
+ expect(await screen.findByText("The household name is required.")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/unit/features/milk-rate-card.test.tsx b/apps/web/tests/unit/features/milk-rate-card.test.tsx
new file mode 100644
index 0000000..26d841b
--- /dev/null
+++ b/apps/web/tests/unit/features/milk-rate-card.test.tsx
@@ -0,0 +1,252 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MilkRateCard } from "@/features/settings/milk-rate-card";
+import { formatIsoDate } from "@/lib/dates";
+import { ApiError } from "@/services/api-client";
+import { getHousehold } from "@/services/household-service";
+import {
+ addMilkRate,
+ deleteMilkRate,
+ getMilkRateInEffect,
+ listMilkRates,
+} from "@/services/milk-rate-service";
+import type { ApiHousehold, ApiMilkRate, ApiProblem } from "@/types/api";
+
+vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() }));
+vi.mock("@/services/milk-rate-service", () => ({
+ listMilkRates: vi.fn(),
+ getMilkRateInEffect: vi.fn(),
+ addMilkRate: vi.fn(),
+ deleteMilkRate: vi.fn(),
+}));
+
+const household: ApiHousehold = {
+ id: "h1",
+ name: "Example household",
+ timeZoneId: "Asia/Kolkata",
+ today: "2026-10-30",
+};
+
+const october: ApiMilkRate = { id: "m1", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6000 };
+const midOctober: ApiMilkRate = { id: "m2", effectiveFrom: "2026-10-15", pricePerLitrePaise: 6400 };
+
+function httpError(status: number, problem?: ApiProblem) {
+ return new ApiError(`The API responded with HTTP ${status}.`, "http", status, { problem });
+}
+
+beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ vi.mocked(getHousehold).mockResolvedValue(household);
+ vi.mocked(listMilkRates).mockResolvedValue([midOctober, october]);
+ vi.mocked(getMilkRateInEffect).mockResolvedValue(midOctober);
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetAllMocks();
+});
+
+describe("MilkRateCard", () => {
+ it("says it is loading until the prices arrive", () => {
+ vi.mocked(getHousehold).mockReturnValue(new Promise(() => {}));
+
+ render( );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Loading prices");
+ });
+
+ it("shows the price in force, as the server decides it, and the history", async () => {
+ render( );
+
+ expect(await screen.findByText("₹64.00 a litre", { selector: "span" })).toBeInTheDocument();
+ expect(screen.getByText(`since ${formatIsoDate("2026-10-15")}`)).toBeInTheDocument();
+
+ const history = screen.getByRole("list");
+ const items = within(history).getAllByRole("listitem");
+ expect(items).toHaveLength(2);
+ expect(within(items[0]!).getByText("In effect")).toBeInTheDocument(); // the 15 October price
+ expect(within(items[1]!).queryByText("In effect")).not.toBeInTheDocument();
+ expect(getMilkRateInEffect).toHaveBeenCalledWith(undefined, expect.anything());
+ });
+
+ it("says plainly that days with no price are not free", async () => {
+ vi.mocked(listMilkRates).mockResolvedValue([]);
+ vi.mocked(getMilkRateInEffect).mockResolvedValue(null);
+
+ render( );
+
+ expect(await screen.findByText(/No price is in force yet/)).toBeInTheDocument();
+ expect(screen.getByText(/never counted as free/)).toBeInTheDocument();
+ expect(screen.queryByRole("list")).not.toBeInTheDocument();
+ });
+
+ it("points to the household section when there is no household yet", async () => {
+ vi.mocked(getHousehold).mockRejectedValue(
+ httpError(404, { status: 404, code: "household-not-set-up" }),
+ );
+ vi.mocked(listMilkRates).mockRejectedValue(
+ httpError(404, { status: 404, code: "household-not-set-up" }),
+ );
+
+ render( );
+
+ expect(await screen.findByText("Set up your household first")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add price" })).not.toBeInTheDocument();
+ });
+
+ it("explains an unreachable API and loads again when you try again", async () => {
+ vi.mocked(getHousehold).mockRejectedValueOnce(new ApiError("down", "unreachable"));
+ const user = userEvent.setup();
+
+ render( );
+ expect(await screen.findByText(/Can't reach the API/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ expect(await screen.findByText("₹64.00 a litre", { selector: "span" })).toBeInTheDocument();
+ });
+
+ it("adds a price in paise, starting today unless told otherwise", async () => {
+ vi.mocked(addMilkRate).mockResolvedValue({
+ id: "m3",
+ effectiveFrom: "2026-10-30",
+ pricePerLitrePaise: 6250,
+ });
+ const user = userEvent.setup();
+
+ render( );
+ const form = await screen.findByRole("form", { name: "Add a price" });
+
+ expect(within(form).getByLabelText("Applies from")).toHaveValue("2026-10-30");
+ await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50");
+ await user.click(within(form).getByRole("button", { name: "Add price" }));
+
+ expect(addMilkRate).toHaveBeenCalledWith({
+ effectiveFrom: "2026-10-30",
+ pricePerLitrePaise: 6250,
+ });
+ expect(listMilkRates).toHaveBeenCalledTimes(2); // refreshed after saving
+ });
+
+ it("can back-date a price", async () => {
+ vi.mocked(addMilkRate).mockResolvedValue(october);
+ const user = userEvent.setup();
+
+ render( );
+ const form = await screen.findByRole("form", { name: "Add a price" });
+ await user.type(within(form).getByLabelText("Price of one litre (₹)"), "60");
+ await user.clear(within(form).getByLabelText("Applies from"));
+ await user.type(within(form).getByLabelText("Applies from"), "2026-09-01");
+ await user.click(within(form).getByRole("button", { name: "Add price" }));
+
+ expect(addMilkRate).toHaveBeenCalledWith({
+ effectiveFrom: "2026-09-01",
+ pricePerLitrePaise: 6000,
+ });
+ });
+
+ it("checks the price before calling the API", async () => {
+ const user = userEvent.setup();
+
+ render( );
+ const form = await screen.findByRole("form", { name: "Add a price" });
+ await user.type(within(form).getByLabelText("Price of one litre (₹)"), "sixty");
+ await user.click(within(form).getByRole("button", { name: "Add price" }));
+
+ expect(await screen.findByText(/Use numbers only/)).toBeInTheDocument();
+ expect(within(form).getByLabelText("Price of one litre (₹)")).toBeInvalid();
+ expect(addMilkRate).not.toHaveBeenCalled();
+ });
+
+ it("explains a day that already has a price", async () => {
+ vi.mocked(addMilkRate).mockRejectedValue(
+ httpError(409, {
+ status: 409,
+ code: "rate-date-taken",
+ detail: "A price already starts on 2026-10-01.",
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ const form = await screen.findByRole("form", { name: "Add a price" });
+ await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50");
+ await user.click(within(form).getByRole("button", { name: "Add price" }));
+
+ expect(await screen.findByText(/A price already starts on that day/)).toBeInTheDocument();
+ });
+
+ it("shows the API's message for an invalid price", async () => {
+ vi.mocked(addMilkRate).mockRejectedValue(
+ httpError(400, {
+ status: 400,
+ code: "validation-failed",
+ errors: {
+ pricePerLitrePaise: ["A price must be between 1 and 1000000 paise a litre (got 0)."],
+ },
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ const form = await screen.findByRole("form", { name: "Add a price" });
+ await user.type(within(form).getByLabelText("Price of one litre (₹)"), "62.50");
+ await user.click(within(form).getByRole("button", { name: "Add price" }));
+
+ expect(
+ await screen.findByText(/A price must be between 1 and 1000000 paise/),
+ ).toBeInTheDocument();
+ });
+
+ it("removes a price only after you confirm", async () => {
+ vi.mocked(deleteMilkRate).mockResolvedValue(undefined);
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(
+ await screen.findByRole("button", {
+ name: `Remove the price from ${formatIsoDate("2026-10-01")}`,
+ }),
+ );
+ const question = screen.getByRole("group", { name: "Confirm removing the price" });
+
+ expect(deleteMilkRate).not.toHaveBeenCalled();
+ await user.click(within(question).getByRole("button", { name: "Keep" }));
+ expect(
+ screen.queryByRole("group", { name: "Confirm removing the price" }),
+ ).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: `Remove the price from ${formatIsoDate("2026-10-01")}` }),
+ );
+ await user.click(
+ within(screen.getByRole("group", { name: "Confirm removing the price" })).getByRole(
+ "button",
+ {
+ name: "Remove price",
+ },
+ ),
+ );
+
+ expect(deleteMilkRate).toHaveBeenCalledWith("m1");
+ expect(listMilkRates).toHaveBeenCalledTimes(2);
+ });
+
+ it("shows what went wrong when removing fails", async () => {
+ vi.mocked(deleteMilkRate).mockRejectedValue(
+ httpError(404, { status: 404, detail: "There is no such price." }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(
+ await screen.findByRole("button", {
+ name: `Remove the price from ${formatIsoDate("2026-10-01")}`,
+ }),
+ );
+ await user.click(screen.getByRole("button", { name: "Remove price" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("There is no such price.");
+ });
+});
diff --git a/apps/web/tests/unit/features/recipients-screen.test.tsx b/apps/web/tests/unit/features/recipients-screen.test.tsx
new file mode 100644
index 0000000..d2e220f
--- /dev/null
+++ b/apps/web/tests/unit/features/recipients-screen.test.tsx
@@ -0,0 +1,442 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { RecipientsScreen } from "@/features/recipients/recipients-screen";
+import { formatIsoDate } from "@/lib/dates";
+import { ApiError } from "@/services/api-client";
+import { getHousehold, setUpHousehold } from "@/services/household-service";
+import {
+ addRecipient,
+ archiveRecipient,
+ listRecipients,
+ unarchiveRecipient,
+ updateRecipient,
+} from "@/services/recipient-service";
+import type { ApiHousehold, ApiProblem, ApiRecipient } from "@/types/api";
+
+vi.mock("@/services/household-service", () => ({ getHousehold: vi.fn(), setUpHousehold: vi.fn() }));
+vi.mock("@/services/recipient-service", () => ({
+ listRecipients: vi.fn(),
+ addRecipient: vi.fn(),
+ updateRecipient: vi.fn(),
+ archiveRecipient: vi.fn(),
+ unarchiveRecipient: vi.fn(),
+}));
+
+const household: ApiHousehold = {
+ id: "h1",
+ name: "Example household",
+ timeZoneId: "Asia/Kolkata",
+ today: "2026-10-30",
+};
+
+const recipientA: ApiRecipient = {
+ id: "r-a",
+ name: "Recipient A",
+ expectedMl: 1000,
+ startsOn: "2026-10-01",
+ archivedFrom: null,
+};
+
+const recipientB: ApiRecipient = {
+ id: "r-b",
+ name: "Recipient B",
+ expectedMl: 500,
+ startsOn: "2026-10-30",
+ archivedFrom: null,
+};
+
+function httpError(status: number, problem?: ApiProblem) {
+ return new ApiError(`The API responded with HTTP ${status}.`, "http", status, { problem });
+}
+
+const notSetUp = () => httpError(404, { status: 404, code: "household-not-set-up" });
+
+beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ vi.mocked(getHousehold).mockResolvedValue(household);
+ vi.mocked(listRecipients).mockResolvedValue([recipientA]);
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetAllMocks();
+});
+
+describe("RecipientsScreen without an API address", () => {
+ it("shows the sample household, labelled as demo data, and never calls the API", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
+
+ render( );
+
+ expect(screen.getByRole("heading", { level: 1, name: "Recipients" })).toBeInTheDocument();
+ expect(screen.getByText("Demo data")).toBeInTheDocument();
+ expect(screen.getByText("Recipient A")).toBeInTheDocument();
+ expect(screen.getByText("Recipient C")).toBeInTheDocument();
+ expect(screen.getByText(/not connected to an API/)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add recipient" })).not.toBeInTheDocument();
+ expect(getHousehold).not.toHaveBeenCalled();
+ expect(listRecipients).not.toHaveBeenCalled();
+ });
+});
+
+describe("RecipientsScreen with an API", () => {
+ it("says it is loading until the recipients arrive", async () => {
+ vi.mocked(getHousehold).mockReturnValue(new Promise(() => {}));
+ vi.mocked(listRecipients).mockReturnValue(new Promise(() => {}));
+
+ render( );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Loading recipients");
+ expect(screen.queryByText("Demo data")).not.toBeInTheDocument();
+ });
+
+ it("lists the household's recipients with their usual quantity, and no demo label", async () => {
+ vi.mocked(listRecipients).mockResolvedValue([recipientA, recipientB]);
+
+ render( );
+
+ expect(await screen.findByText("Recipient A")).toBeInTheDocument();
+ expect(screen.getByText("Recipient B")).toBeInTheDocument();
+ expect(screen.getByText("1 L a day")).toBeInTheDocument();
+ expect(screen.getByText(/500 ml a day/)).toBeInTheDocument();
+ expect(screen.queryByText("Demo data")).not.toBeInTheDocument();
+ expect(listRecipients).toHaveBeenCalledWith(
+ expect.objectContaining({ includeArchived: false }),
+ );
+ });
+
+ it("says when a recipient has not started yet", async () => {
+ vi.mocked(listRecipients).mockResolvedValue([
+ recipientB,
+ { ...recipientA, startsOn: "2026-11-05" },
+ ]);
+
+ render( );
+
+ expect(
+ await screen.findByText(new RegExp(`starts ${formatIsoDate("2026-11-05")}`)),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(new RegExp(`starts ${formatIsoDate("2026-10-30")}`)),
+ ).not.toBeInTheDocument();
+ });
+
+ it("invites you to add the first recipient when there are none", async () => {
+ vi.mocked(listRecipients).mockResolvedValue([]);
+
+ render( );
+
+ expect(await screen.findByRole("heading", { name: "No recipients yet" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add the first recipient" })).toBeInTheDocument();
+ });
+
+ it("asks you to set up the household first, then shows the recipients", async () => {
+ vi.mocked(getHousehold).mockRejectedValueOnce(notSetUp()).mockResolvedValue(household);
+ vi.mocked(listRecipients).mockRejectedValueOnce(notSetUp()).mockResolvedValue([]);
+ vi.mocked(setUpHousehold).mockResolvedValue(household);
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(
+ await screen.findByRole("heading", { name: "Set up your household first" }),
+ ).toBeInTheDocument();
+ await user.type(screen.getByLabelText("Household name"), "Example household");
+ await user.click(screen.getByRole("button", { name: "Create household" }));
+
+ expect(setUpHousehold).toHaveBeenCalledWith({
+ name: "Example household",
+ timeZoneId: "Asia/Kolkata",
+ });
+ expect(await screen.findByRole("heading", { name: "No recipients yet" })).toBeInTheDocument();
+ });
+
+ it("shows the API's message under the household name field", async () => {
+ vi.mocked(getHousehold).mockRejectedValue(notSetUp());
+ vi.mocked(listRecipients).mockRejectedValue(notSetUp());
+ vi.mocked(setUpHousehold).mockRejectedValue(
+ httpError(400, {
+ status: 400,
+ code: "validation-failed",
+ errors: { name: ["The household name is required."] },
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Create household" }));
+
+ expect(await screen.findByText("The household name is required.")).toBeInTheDocument();
+ expect(screen.getByLabelText("Household name")).toBeInvalid();
+ });
+
+ it("explains an unreachable API and loads again when you try again", async () => {
+ vi.mocked(getHousehold)
+ .mockRejectedValueOnce(new ApiError("down", "unreachable"))
+ .mockResolvedValue(household);
+ vi.mocked(listRecipients).mockRejectedValueOnce(new ApiError("down", "unreachable"));
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(
+ await screen.findByText(/Can't reach the API at http:\/\/localhost:5080/),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Recipient A")).not.toBeInTheDocument();
+ expect(screen.queryByText("Demo data")).not.toBeInTheDocument();
+
+ vi.mocked(listRecipients).mockResolvedValue([recipientA]);
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ expect(await screen.findByText("Recipient A")).toBeInTheDocument();
+ });
+
+ it("shows the server's explanation for other failures", async () => {
+ vi.mocked(getHousehold).mockRejectedValue(httpError(500));
+
+ render( );
+
+ expect(await screen.findByText(/The API answered with HTTP 500/)).toBeInTheDocument();
+ });
+});
+
+describe("adding a recipient", () => {
+ async function openAddForm(user: ReturnType) {
+ await user.click(await screen.findByRole("button", { name: "Add recipient" }));
+ return screen.getByRole("form", { name: "Add a recipient" });
+ }
+
+ it("saves the recipient, closes the form and shows the new list", async () => {
+ vi.mocked(listRecipients)
+ .mockResolvedValueOnce([recipientA])
+ .mockResolvedValue([recipientA, recipientB]);
+ vi.mocked(addRecipient).mockResolvedValue(recipientB);
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+
+ expect(within(form).getByLabelText("First delivery on")).toHaveValue("2026-10-30"); // today, from the server
+ expect(within(form).getByLabelText("Name")).toHaveFocus();
+
+ await user.type(within(form).getByLabelText("Name"), "Recipient B");
+ await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500");
+ expect(within(form).getByText("That is 500 ml a day.")).toBeInTheDocument();
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(addRecipient).toHaveBeenCalledWith({
+ name: "Recipient B",
+ expectedMl: 500,
+ startsOn: "2026-10-30",
+ });
+ expect(await screen.findByText("Recipient B")).toBeInTheDocument();
+ expect(screen.queryByRole("form", { name: "Add a recipient" })).not.toBeInTheDocument();
+ });
+
+ it("shows the API's messages under the fields they are about and keeps what was typed", async () => {
+ vi.mocked(addRecipient).mockRejectedValue(
+ httpError(400, {
+ status: 400,
+ code: "validation-failed",
+ errors: {
+ name: ["The recipient's name is required."],
+ expectedMl: ["The usual quantity must be between 1 ml and 20000 ml (got 30000 ml)."],
+ },
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+ await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "30000");
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(await screen.findByText("The recipient's name is required.")).toBeInTheDocument();
+ expect(screen.getByText(/must be between 1 ml and 20000 ml/)).toBeInTheDocument();
+ expect(within(form).getByLabelText("Name")).toBeInvalid();
+ expect(within(form).getByLabelText("Usual daily quantity (ml)")).toHaveValue("30000");
+ });
+
+ it("explains a name that is already taken", async () => {
+ vi.mocked(addRecipient).mockRejectedValue(
+ httpError(409, {
+ status: 409,
+ code: "recipient-name-taken",
+ detail: "Another recipient ...",
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+ await user.type(within(form).getByLabelText("Name"), "Recipient A");
+ await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500");
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(await screen.findByText(/already called that/)).toBeInTheDocument();
+ });
+
+ it("checks the quantity is a whole number before calling the API", async () => {
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+ await user.type(within(form).getByLabelText("Name"), "Recipient B");
+ await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "1.5 litres");
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(
+ await screen.findByText("Enter a whole number of millilitres, for example 500."),
+ ).toBeInTheDocument();
+ expect(addRecipient).not.toHaveBeenCalled();
+ });
+
+ it("shows a message in the form when the API cannot be reached", async () => {
+ vi.mocked(addRecipient).mockRejectedValue(new ApiError("down", "unreachable"));
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+ await user.type(within(form).getByLabelText("Name"), "Recipient B");
+ await user.type(within(form).getByLabelText("Usual daily quantity (ml)"), "500");
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(await within(form).findByRole("alert")).toHaveTextContent("Can't reach the API");
+ expect(within(form).getByRole("button", { name: "Save recipient" })).toBeEnabled();
+ });
+
+ it("closes the form without saving when you cancel", async () => {
+ const user = userEvent.setup();
+
+ render( );
+ const form = await openAddForm(user);
+ await user.click(within(form).getByRole("button", { name: "Cancel" }));
+
+ expect(screen.queryByRole("form", { name: "Add a recipient" })).not.toBeInTheDocument();
+ expect(addRecipient).not.toHaveBeenCalled();
+ });
+});
+
+describe("changing a recipient", () => {
+ it("edits the details in place", async () => {
+ vi.mocked(updateRecipient).mockResolvedValue({ ...recipientA, name: "Recipient Z" });
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Edit Recipient A" }));
+ const form = screen.getByRole("form", { name: "Edit Recipient A" });
+
+ expect(within(form).getByLabelText("Name")).toHaveValue("Recipient A");
+ expect(within(form).getByLabelText("Usual daily quantity (ml)")).toHaveValue("1000");
+ expect(within(form).getByLabelText("First delivery on")).toHaveValue("2026-10-01");
+
+ await user.clear(within(form).getByLabelText("Name"));
+ await user.type(within(form).getByLabelText("Name"), "Recipient Z");
+ await user.click(within(form).getByRole("button", { name: "Save recipient" }));
+
+ expect(updateRecipient).toHaveBeenCalledWith("r-a", {
+ name: "Recipient Z",
+ expectedMl: 1000,
+ startsOn: "2026-10-01",
+ });
+ expect(screen.queryByRole("form", { name: "Edit Recipient A" })).not.toBeInTheDocument();
+ });
+
+ it("archives only after you confirm, and keeps the recipient if you change your mind", async () => {
+ vi.mocked(archiveRecipient).mockResolvedValue({ ...recipientA, archivedFrom: "2026-10-30" });
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Archive Recipient A" }));
+ const question = screen.getByRole("group", { name: "Archive Recipient A?" });
+
+ expect(archiveRecipient).not.toHaveBeenCalled();
+ expect(within(question).getByText(/Their history is kept/)).toBeInTheDocument();
+
+ await user.click(within(question).getByRole("button", { name: "Keep" }));
+ expect(screen.queryByRole("group", { name: "Archive Recipient A?" })).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Archive Recipient A" }));
+ await user.click(
+ within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", {
+ name: "Archive Recipient A",
+ }),
+ );
+
+ expect(archiveRecipient).toHaveBeenCalledTimes(1);
+ expect(archiveRecipient).toHaveBeenCalledWith("r-a");
+ expect(listRecipients).toHaveBeenCalledTimes(2); // refreshed after saving
+ });
+
+ it("shows what went wrong when archiving fails", async () => {
+ vi.mocked(archiveRecipient).mockRejectedValue(
+ httpError(409, {
+ status: 409,
+ code: "recipient-already-archived",
+ detail: "The recipient is already archived.",
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Archive Recipient A" }));
+ await user.click(
+ within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", {
+ name: "Archive Recipient A",
+ }),
+ );
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "The recipient is already archived.",
+ );
+ });
+
+ it("says why archiving was refused when the API names a field but gives no explanation", async () => {
+ vi.mocked(archiveRecipient).mockRejectedValue(
+ httpError(400, {
+ status: 400,
+ code: "validation-failed",
+ errors: { archivedFrom: ["A recipient must be active for at least one day."] },
+ }),
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Archive Recipient A" }));
+ await user.click(
+ within(screen.getByRole("group", { name: "Archive Recipient A?" })).getByRole("button", {
+ name: "Archive Recipient A",
+ }),
+ );
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "A recipient must be active for at least one day.",
+ );
+ });
+
+ it("shows archived recipients on request and restores one", async () => {
+ const archived: ApiRecipient = { ...recipientA, archivedFrom: "2026-10-15" };
+ vi.mocked(listRecipients).mockImplementation(async ({ includeArchived } = {}) =>
+ includeArchived ? [archived, recipientB] : [recipientB],
+ );
+ vi.mocked(unarchiveRecipient).mockResolvedValue(recipientA);
+ const user = userEvent.setup();
+
+ render( );
+ expect(await screen.findByText("Recipient B")).toBeInTheDocument();
+ expect(screen.queryByText("Recipient A")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("checkbox", { name: "Show archived recipients" }));
+
+ expect(await screen.findByText("Recipient A")).toBeInTheDocument();
+ expect(screen.getByText(`Archived from ${formatIsoDate("2026-10-15")}`)).toBeInTheDocument();
+ expect(listRecipients).toHaveBeenLastCalledWith(
+ expect.objectContaining({ includeArchived: true }),
+ );
+
+ await user.click(screen.getByRole("button", { name: "Restore Recipient A" }));
+
+ expect(unarchiveRecipient).toHaveBeenCalledWith("r-a");
+ });
+});
diff --git a/apps/web/tests/unit/lib/dates.test.ts b/apps/web/tests/unit/lib/dates.test.ts
new file mode 100644
index 0000000..1e629c6
--- /dev/null
+++ b/apps/web/tests/unit/lib/dates.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { formatIsoDate, isIsoDate } from "@/lib/dates";
+
+describe("isIsoDate", () => {
+ it("accepts real calendar dates", () => {
+ expect(isIsoDate("2026-10-30")).toBe(true);
+ expect(isIsoDate("2028-02-29")).toBe(true);
+ });
+
+ it.each([
+ "2026-02-30",
+ "2026-13-01",
+ "2026-00-10",
+ "2027-02-29",
+ "30/10/2026",
+ "2026-1-5",
+ "",
+ "today",
+ ])("rejects %j", (value) => {
+ expect(isIsoDate(value)).toBe(false);
+ });
+});
+
+describe("formatIsoDate", () => {
+ it("shows the calendar date written, whatever the machine's time zone", () => {
+ expect(formatIsoDate("2026-10-30", "en-GB")).toBe("30 Oct 2026");
+ expect(formatIsoDate("2026-01-01", "en-GB")).toBe("1 Jan 2026");
+ expect(formatIsoDate("2026-12-31", "en-GB")).toBe("31 Dec 2026");
+ });
+
+ it("returns anything that is not a date as it is", () => {
+ expect(formatIsoDate("soon", "en-GB")).toBe("soon");
+ expect(formatIsoDate("2026-02-30", "en-GB")).toBe("2026-02-30");
+ });
+});
diff --git a/apps/web/tests/unit/lib/money.test.ts b/apps/web/tests/unit/lib/money.test.ts
new file mode 100644
index 0000000..d0fdced
--- /dev/null
+++ b/apps/web/tests/unit/lib/money.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { MAX_PRICE_PAISE, formatPaise, formatPricePerLitre, parseRupees } from "@/lib/money";
+
+describe("formatPaise", () => {
+ it("shows rupees and paise", () => {
+ expect(formatPaise(6250)).toBe("₹62.50");
+ expect(formatPaise(6000)).toBe("₹60.00");
+ expect(formatPaise(1)).toBe("₹0.01");
+ });
+
+ it("groups large amounts the Indian way", () => {
+ expect(formatPaise(1_000_000)).toBe("₹10,000.00");
+ expect(formatPaise(12_345_600)).toBe("₹1,23,456.00");
+ });
+});
+
+describe("formatPricePerLitre", () => {
+ it("adds the unit", () => {
+ expect(formatPricePerLitre(6250)).toBe("₹62.50 a litre");
+ });
+});
+
+describe("parseRupees", () => {
+ it.each([
+ ["62.50", 6250],
+ ["62.5", 6250],
+ ["62", 6200],
+ ["0.01", 1],
+ ["0.1", 10],
+ ["10000", 1_000_000],
+ ["₹62.50", 6250],
+ [" ₹ 62 ", 6200],
+ ])("reads %j as %i paise", (text, paise) => {
+ expect(parseRupees(text)).toEqual({ ok: true, paise });
+ });
+
+ it("converts without floating-point error", () => {
+ // 0.07 * 100 is 7.000000000000001 in floating point.
+ expect(parseRupees("0.07")).toEqual({ ok: true, paise: 7 });
+ expect(parseRupees("19.99")).toEqual({ ok: true, paise: 1999 });
+ });
+
+ it("asks for a price when the text is empty", () => {
+ expect(parseRupees(" ")).toMatchObject({
+ ok: false,
+ message: expect.stringContaining("Enter"),
+ });
+ });
+
+ it.each(["abc", "62,50", "62.505", "-5", "1e3", "6 2", ".5", "62.", "₹₹62"])(
+ "rejects %j as not a price",
+ (text) => {
+ expect(parseRupees(text)).toMatchObject({
+ ok: false,
+ message: expect.stringContaining("numbers only"),
+ });
+ },
+ );
+
+ it("rejects zero and anything above the largest allowed price", () => {
+ for (const text of ["0", "0.00", "10000.01", "99999"]) {
+ expect(parseRupees(text)).toMatchObject({
+ ok: false,
+ message: expect.stringContaining("between"),
+ });
+ }
+ expect(MAX_PRICE_PAISE).toBe(1_000_000);
+ });
+});
diff --git a/apps/web/tests/unit/services/api-client.test.ts b/apps/web/tests/unit/services/api-client.test.ts
index 8c4d941..8697c98 100644
--- a/apps/web/tests/unit/services/api-client.test.ts
+++ b/apps/web/tests/unit/services/api-client.test.ts
@@ -1,6 +1,15 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
-import { ApiError, apiGet, getApiBaseUrl } from "@/services/api-client";
+import {
+ ApiError,
+ apiDelete,
+ apiGet,
+ apiPost,
+ apiPut,
+ fieldErrors,
+ getApiBaseUrl,
+ hasProblemCode,
+} from "@/services/api-client";
import { getApiStatus } from "@/services/status-service";
afterEach(() => {
@@ -17,6 +26,13 @@ function stubFetch(response: Response | Error) {
return fetchMock;
}
+function problemResponse(status: number, body: object) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/problem+json" },
+ });
+}
+
describe("getApiBaseUrl", () => {
it("drops trailing slashes", () => {
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080//");
@@ -48,7 +64,7 @@ describe("apiGet", () => {
await expect(getApiStatus()).resolves.toEqual(body);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:5080/api/v1/status",
- expect.objectContaining({ headers: { Accept: "application/json" } }),
+ expect.objectContaining({ method: "GET", headers: { Accept: "application/json" } }),
);
});
@@ -59,6 +75,7 @@ describe("apiGet", () => {
const error = await apiGet("/status").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ kind: "http", status: 503 });
+ expect((error as ApiError).problem).toBeUndefined();
});
it("reports network failures as unreachable", async () => {
@@ -68,3 +85,139 @@ describe("apiGet", () => {
await expect(apiGet("/status")).rejects.toMatchObject({ kind: "unreachable" });
});
});
+
+describe("writing", () => {
+ it("sends a JSON body with POST and PUT", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ // A new Response per call: a body can only be read once.
+ const fetchMock = vi.fn(async () => Response.json({ id: "1" }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await apiPost("/recipients", { name: "Recipient A" });
+ await apiPut("/household", { name: "Home" });
+
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ "http://localhost:5080/api/v1/recipients",
+ expect.objectContaining({
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Recipient A" }),
+ }),
+ );
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ "http://localhost:5080/api/v1/household",
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ name: "Home" }) }),
+ );
+ });
+
+ it("sends no body and no content type when there is nothing to send", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ const fetchMock = stubFetch(Response.json({}));
+
+ await apiPost("/recipients/1/archive");
+
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
+ expect(init.body).toBeUndefined();
+ expect(init.headers).toEqual({ Accept: "application/json" });
+ });
+
+ it("treats 204 No Content as success with no body", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ const fetchMock = stubFetch(new Response(null, { status: 204 }));
+
+ await expect(apiDelete("/milk-rates/1")).resolves.toBeUndefined();
+ expect(fetchMock).toHaveBeenCalledWith(
+ "http://localhost:5080/api/v1/milk-rates/1",
+ expect.objectContaining({ method: "DELETE" }),
+ );
+ });
+
+ it("does not call the API when its address is not configured", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
+ const fetchMock = stubFetch(Response.json({}));
+
+ await expect(apiPost("/recipients", {})).rejects.toMatchObject({ kind: "not-configured" });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("Problem Details", () => {
+ it("keeps the problem body, with its code, on the error", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ stubFetch(
+ problemResponse(409, {
+ status: 409,
+ title: "Conflict",
+ detail: "Another recipient who is not archived is already called 'Recipient A'.",
+ code: "recipient-name-taken",
+ }),
+ );
+
+ const error = await apiPost("/recipients", {}).catch((caught: unknown) => caught);
+
+ expect(error).toMatchObject({ kind: "http", status: 409 });
+ expect((error as ApiError).problem?.detail).toContain("already called");
+ expect(hasProblemCode(error, "recipient-name-taken")).toBe(true);
+ expect(hasProblemCode(error, "rate-date-taken")).toBe(false);
+ });
+
+ it("reads the first message for each invalid field", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ stubFetch(
+ problemResponse(400, {
+ status: 400,
+ title: "One or more validation errors occurred.",
+ code: "validation-failed",
+ errors: {
+ name: ["The recipient's name is required.", "Another message."],
+ expectedMl: ["Too small."],
+ },
+ }),
+ );
+
+ const error = await apiPost("/recipients", {}).catch((caught: unknown) => caught);
+
+ expect(fieldErrors(error)).toEqual({
+ name: "The recipient's name is required.",
+ expectedMl: "Too small.",
+ });
+ });
+
+ it("copes with an error body that is not JSON", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ stubFetch(
+ new Response("Bad gateway", {
+ status: 502,
+ headers: { "Content-Type": "text/html" },
+ }),
+ );
+
+ const error = await apiGet("/status").catch((caught: unknown) => caught);
+
+ expect(error).toMatchObject({ kind: "http", status: 502 });
+ expect(fieldErrors(error)).toEqual({});
+ expect(hasProblemCode(error, "anything")).toBe(false);
+ });
+
+ it("copes with JSON that does not parse", async () => {
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080");
+ stubFetch(
+ new Response("{ nope", {
+ status: 500,
+ headers: { "Content-Type": "application/problem+json" },
+ }),
+ );
+
+ const error = await apiGet("/status").catch((caught: unknown) => caught);
+
+ expect(error).toMatchObject({ kind: "http", status: 500 });
+ expect((error as ApiError).problem).toBeUndefined();
+ });
+
+ it("returns no field errors for errors that are not API errors", () => {
+ expect(fieldErrors(new Error("boom"))).toEqual({});
+ expect(fieldErrors(undefined)).toEqual({});
+ });
+});
diff --git a/apps/web/tests/unit/services/resource-services.test.ts b/apps/web/tests/unit/services/resource-services.test.ts
new file mode 100644
index 0000000..8e724e1
--- /dev/null
+++ b/apps/web/tests/unit/services/resource-services.test.ts
@@ -0,0 +1,202 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getHousehold, setUpHousehold } from "@/services/household-service";
+import {
+ addMilkRate,
+ deleteMilkRate,
+ getMilkRateInEffect,
+ listMilkRates,
+} from "@/services/milk-rate-service";
+import {
+ addRecipient,
+ archiveRecipient,
+ listRecipients,
+ unarchiveRecipient,
+ updateRecipient,
+} from "@/services/recipient-service";
+
+const BASE = "http://localhost:5080/api/v1";
+
+beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:5080"));
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+});
+
+function stubFetch(response: Response) {
+ const fetchMock = vi.fn().mockResolvedValue(response);
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+/** The method, URL and parsed JSON body of the one request that was made. */
+function sent(fetchMock: ReturnType) {
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ return {
+ url,
+ method: init.method,
+ body: typeof init.body === "string" ? (JSON.parse(init.body) as unknown) : undefined,
+ };
+}
+
+function noRateProblem() {
+ return new Response(JSON.stringify({ status: 404, code: "no-rate-in-effect" }), {
+ status: 404,
+ headers: { "Content-Type": "application/problem+json" },
+ });
+}
+
+describe("household service", () => {
+ it("reads the household", async () => {
+ const household = { id: "h", name: "Home", timeZoneId: "Asia/Kolkata", today: "2026-10-30" };
+ const fetchMock = stubFetch(Response.json(household));
+
+ await expect(getHousehold()).resolves.toEqual(household);
+ expect(sent(fetchMock)).toMatchObject({ url: `${BASE}/household`, method: "GET" });
+ });
+
+ it("sets the household up with a PUT", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "h" }));
+
+ await setUpHousehold({ name: "Home", timeZoneId: "Europe/London" });
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/household`,
+ method: "PUT",
+ body: { name: "Home", timeZoneId: "Europe/London" },
+ });
+ });
+});
+
+describe("recipient service", () => {
+ it("lists the recipients from the items of the response", async () => {
+ const recipient = {
+ id: "r",
+ name: "Recipient A",
+ expectedMl: 1000,
+ startsOn: "2026-10-01",
+ archivedFrom: null,
+ };
+ const fetchMock = stubFetch(Response.json({ items: [recipient] }));
+
+ await expect(listRecipients()).resolves.toEqual([recipient]);
+ expect(sent(fetchMock).url).toBe(`${BASE}/recipients`);
+ });
+
+ it("asks for archived recipients only when told to", async () => {
+ const fetchMock = stubFetch(Response.json({ items: [] }));
+
+ await listRecipients({ includeArchived: true });
+
+ expect(sent(fetchMock).url).toBe(`${BASE}/recipients?includeArchived=true`);
+ });
+
+ it("adds a recipient with a POST", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "r" }, { status: 201 }));
+
+ await addRecipient({ name: "Recipient A", expectedMl: 1000, startsOn: "2026-10-01" });
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/recipients`,
+ method: "POST",
+ body: { name: "Recipient A", expectedMl: 1000, startsOn: "2026-10-01" },
+ });
+ });
+
+ it("updates a recipient with a PUT to its address", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "r/1" }));
+
+ await updateRecipient("r/1", { name: "Recipient B", expectedMl: 500, startsOn: "2026-10-01" });
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/recipients/r%2F1`,
+ method: "PUT",
+ body: { name: "Recipient B", expectedMl: 500, startsOn: "2026-10-01" },
+ });
+ });
+
+ it("archives from today by sending no date", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "r" }));
+
+ await archiveRecipient("r");
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/recipients/r/archive`,
+ method: "POST",
+ body: undefined,
+ });
+ });
+
+ it("archives from a chosen date", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "r" }));
+
+ await archiveRecipient("r", "2026-11-15");
+
+ expect(sent(fetchMock).body).toEqual({ archivedFrom: "2026-11-15" });
+ });
+
+ it("restores an archived recipient", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "r" }));
+
+ await unarchiveRecipient("r");
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/recipients/r/unarchive`,
+ method: "POST",
+ body: undefined,
+ });
+ });
+});
+
+describe("milk rate service", () => {
+ it("lists the rates from the items of the response", async () => {
+ const rate = { id: "m", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 };
+ const fetchMock = stubFetch(Response.json({ items: [rate] }));
+
+ await expect(listMilkRates()).resolves.toEqual([rate]);
+ expect(sent(fetchMock).url).toBe(`${BASE}/milk-rates`);
+ });
+
+ it("asks which rate is in force today, or on a date", async () => {
+ const rate = { id: "m", effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 };
+ const today = stubFetch(Response.json(rate));
+ await expect(getMilkRateInEffect()).resolves.toEqual(rate);
+ expect(sent(today).url).toBe(`${BASE}/milk-rates/in-effect`);
+
+ const onDate = stubFetch(Response.json(rate));
+ await getMilkRateInEffect("2026-10-14");
+ expect(sent(onDate).url).toBe(`${BASE}/milk-rates/in-effect?on=2026-10-14`);
+ });
+
+ it("answers null, not an error, when no price has started yet", async () => {
+ stubFetch(noRateProblem());
+
+ await expect(getMilkRateInEffect()).resolves.toBeNull();
+ });
+
+ it("still fails for other errors", async () => {
+ stubFetch(new Response(null, { status: 500 }));
+
+ await expect(getMilkRateInEffect()).rejects.toMatchObject({ kind: "http", status: 500 });
+ });
+
+ it("adds a rate with a POST", async () => {
+ const fetchMock = stubFetch(Response.json({ id: "m" }, { status: 201 }));
+
+ await addMilkRate({ effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 });
+
+ expect(sent(fetchMock)).toEqual({
+ url: `${BASE}/milk-rates`,
+ method: "POST",
+ body: { effectiveFrom: "2026-10-01", pricePerLitrePaise: 6250 },
+ });
+ });
+
+ it("deletes a rate", async () => {
+ const fetchMock = stubFetch(new Response(null, { status: 204 }));
+
+ await expect(deleteMilkRate("m")).resolves.toBeUndefined();
+ expect(sent(fetchMock)).toMatchObject({ url: `${BASE}/milk-rates/m`, method: "DELETE" });
+ });
+});
diff --git a/backend/PureFreshMilk.slnx b/backend/PureFreshMilk.slnx
index 696ed1a..a92e71c 100644
--- a/backend/PureFreshMilk.slnx
+++ b/backend/PureFreshMilk.slnx
@@ -7,7 +7,10 @@
+
+
+
diff --git a/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs
new file mode 100644
index 0000000..bc17158
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdEndpoints.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Http.HttpResults;
+using PureFreshMilk.Api.Hosting;
+using PureFreshMilk.Application.Households;
+
+namespace PureFreshMilk.Api.Features.Households;
+
+internal static class HouseholdEndpoints
+{
+ public static IEndpointRouteBuilder MapHouseholdEndpoints(this IEndpointRouteBuilder api)
+ {
+ var household = api.MapGroup("/household").WithTags("Household");
+
+ household.MapGet(
+ string.Empty,
+ async (GetHouseholdHandler handler, CancellationToken cancellationToken) =>
+ TypedResults.Ok(HouseholdResponse.From(await handler.HandleAsync(cancellationToken))))
+ .WithName("GetHousehold")
+ .WithSummary("Reads the household, including today's date in its time zone.");
+
+ household.MapPut(
+ string.Empty,
+ async Task, Ok>> (
+ SetUpHouseholdRequest request, SetUpHouseholdHandler handler, CancellationToken cancellationToken) =>
+ {
+ var result = await handler.HandleAsync(
+ new SetUpHouseholdCommand(request.Name, request.TimeZoneId), cancellationToken);
+ var body = HouseholdResponse.From(result.Household);
+
+ return result.Created
+ ? TypedResults.Created($"{ApiRoutes.Prefix}/household", body)
+ : TypedResults.Ok(body);
+ })
+ .WithName("SetUpHousehold")
+ .WithSummary("Creates the household the first time and updates it afterwards.");
+
+ return api;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs
new file mode 100644
index 0000000..bbbe544
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Households/HouseholdResponse.cs
@@ -0,0 +1,12 @@
+using PureFreshMilk.Application.Households;
+
+namespace PureFreshMilk.Api.Features.Households;
+
+/// Response body for GET and PUT /api/v1/household .
+/// An IANA time zone id, for example Asia/Kolkata .
+/// The calendar date it is now in the household's time zone, decided by the server.
+internal sealed record HouseholdResponse(Guid Id, string Name, string TimeZoneId, DateOnly Today)
+{
+ public static HouseholdResponse From(HouseholdDetails household) =>
+ new(household.Id, household.Name, household.TimeZoneId, household.Today);
+}
diff --git a/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs b/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs
new file mode 100644
index 0000000..c633e75
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Households/SetUpHouseholdRequest.cs
@@ -0,0 +1,5 @@
+namespace PureFreshMilk.Api.Features.Households;
+
+/// Request body for PUT /api/v1/household .
+/// An IANA id. Left out, it defaults to Asia/Kolkata on creation and is kept on update.
+internal sealed record SetUpHouseholdRequest(string? Name, string? TimeZoneId);
diff --git a/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs b/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs
new file mode 100644
index 0000000..5cccb6e
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/ItemsResponse.cs
@@ -0,0 +1,7 @@
+namespace PureFreshMilk.Api.Features;
+
+///
+/// The body of every list endpoint: an object with an items array, so a field can be added later
+/// without breaking clients.
+///
+internal sealed record ItemsResponse(IReadOnlyList Items);
diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs
new file mode 100644
index 0000000..197c938
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateEndpoints.cs
@@ -0,0 +1,67 @@
+using PureFreshMilk.Api.Hosting;
+using PureFreshMilk.Application.Rates;
+
+namespace PureFreshMilk.Api.Features.MilkRates;
+
+internal static class MilkRateEndpoints
+{
+ public static IEndpointRouteBuilder MapMilkRateEndpoints(this IEndpointRouteBuilder api)
+ {
+ var rates = api.MapGroup("/milk-rates").WithTags("Milk rates");
+
+ rates.MapGet(
+ string.Empty,
+ async (ListMilkRatesHandler handler, CancellationToken cancellationToken) =>
+ {
+ var found = await handler.HandleAsync(cancellationToken);
+
+ return TypedResults.Ok(new ItemsResponse([.. found.Select(MilkRateResponse.From)]));
+ })
+ .WithName("ListMilkRates")
+ .WithSummary("Lists the rates, newest effective date first.");
+
+ rates.MapGet(
+ "in-effect",
+ async (DateOnly? on, GetRateInEffectHandler handler, CancellationToken cancellationToken) =>
+ TypedResults.Ok(MilkRateResponse.From(await handler.HandleAsync(on, cancellationToken))))
+ .WithName("GetMilkRateInEffect")
+ .WithSummary("The rate in force on a date (default: today). 404 if no rate has started yet.");
+
+ rates.MapPost(
+ string.Empty,
+ async (MilkRateRequest request, AddMilkRateHandler handler, CancellationToken cancellationToken) =>
+ {
+ var added = await handler.HandleAsync(
+ new AddMilkRateCommand(request.EffectiveFrom, request.PricePerLitrePaise), cancellationToken);
+
+ return TypedResults.Created($"{ApiRoutes.Prefix}/milk-rates/{added.Id}", MilkRateResponse.From(added));
+ })
+ .WithName("AddMilkRate")
+ .WithSummary("Adds a rate.");
+
+ rates.MapPut(
+ "{id:guid}",
+ async (Guid id, MilkRateRequest request, UpdateMilkRateHandler handler, CancellationToken cancellationToken) =>
+ {
+ var updated = await handler.HandleAsync(
+ new UpdateMilkRateCommand(id, request.EffectiveFrom, request.PricePerLitrePaise), cancellationToken);
+
+ return TypedResults.Ok(MilkRateResponse.From(updated));
+ })
+ .WithName("UpdateMilkRate")
+ .WithSummary("Changes the date or price of a rate.");
+
+ rates.MapDelete(
+ "{id:guid}",
+ async (Guid id, DeleteMilkRateHandler handler, CancellationToken cancellationToken) =>
+ {
+ await handler.HandleAsync(id, cancellationToken);
+
+ return TypedResults.NoContent();
+ })
+ .WithName("DeleteMilkRate")
+ .WithSummary("Removes a rate.");
+
+ return api;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs
new file mode 100644
index 0000000..c380009
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateRequests.cs
@@ -0,0 +1,6 @@
+namespace PureFreshMilk.Api.Features.MilkRates;
+
+/// Request body for POST /api/v1/milk-rates and PUT /api/v1/milk-rates/{id} .
+/// 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.
+internal sealed record MilkRateRequest(DateOnly EffectiveFrom, long PricePerLitrePaise);
diff --git a/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs
new file mode 100644
index 0000000..322d4c9
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/MilkRates/MilkRateResponse.cs
@@ -0,0 +1,12 @@
+using PureFreshMilk.Application.Rates;
+
+namespace PureFreshMilk.Api.Features.MilkRates;
+
+/// A milk rate in API responses.
+/// The first day this price applies (YYYY-MM-DD ).
+/// The price of one litre, in whole paise.
+internal sealed record MilkRateResponse(Guid Id, DateOnly EffectiveFrom, long PricePerLitrePaise)
+{
+ public static MilkRateResponse From(MilkRateDetails rate) =>
+ new(rate.Id, rate.EffectiveFrom, rate.PricePerLitrePaise);
+}
diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs
new file mode 100644
index 0000000..e69f9d3
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientEndpoints.cs
@@ -0,0 +1,77 @@
+using PureFreshMilk.Api.Hosting;
+using PureFreshMilk.Application.Recipients;
+
+namespace PureFreshMilk.Api.Features.Recipients;
+
+internal static class RecipientEndpoints
+{
+ public static IEndpointRouteBuilder MapRecipientEndpoints(this IEndpointRouteBuilder api)
+ {
+ var recipients = api.MapGroup("/recipients").WithTags("Recipients");
+
+ recipients.MapGet(
+ string.Empty,
+ async (bool? includeArchived, ListRecipientsHandler handler, CancellationToken cancellationToken) =>
+ {
+ var found = await handler.HandleAsync(includeArchived ?? false, cancellationToken);
+
+ return TypedResults.Ok(new ItemsResponse([.. found.Select(RecipientResponse.From)]));
+ })
+ .WithName("ListRecipients")
+ .WithSummary("Lists the recipients by name. Archived ones are left out unless includeArchived is true.");
+
+ recipients.MapPost(
+ string.Empty,
+ async (AddRecipientRequest request, AddRecipientHandler handler, CancellationToken cancellationToken) =>
+ {
+ var added = await handler.HandleAsync(
+ new AddRecipientCommand(request.Name, request.ExpectedMl, request.StartsOn), cancellationToken);
+
+ return TypedResults.Created($"{ApiRoutes.Prefix}/recipients/{added.Id}", RecipientResponse.From(added));
+ })
+ .WithName("AddRecipient")
+ .WithSummary("Adds a recipient.");
+
+ recipients.MapGet(
+ "{id:guid}",
+ async (Guid id, GetRecipientHandler handler, CancellationToken cancellationToken) =>
+ TypedResults.Ok(RecipientResponse.From(await handler.HandleAsync(id, cancellationToken))))
+ .WithName("GetRecipient")
+ .WithSummary("Reads one recipient.");
+
+ recipients.MapPut(
+ "{id:guid}",
+ async (
+ Guid id, UpdateRecipientRequest request, UpdateRecipientHandler handler, CancellationToken cancellationToken) =>
+ {
+ var updated = await handler.HandleAsync(
+ new UpdateRecipientCommand(id, request.Name, request.ExpectedMl, request.StartsOn), cancellationToken);
+
+ return TypedResults.Ok(RecipientResponse.From(updated));
+ })
+ .WithName("UpdateRecipient")
+ .WithSummary("Replaces the name, usual quantity and start date of a recipient.");
+
+ recipients.MapPost(
+ "{id:guid}/archive",
+ async (
+ Guid id, ArchiveRecipientRequest? request, ArchiveRecipientHandler handler, CancellationToken cancellationToken) =>
+ {
+ var archived = await handler.HandleAsync(
+ new ArchiveRecipientCommand(id, request?.ArchivedFrom), cancellationToken);
+
+ return TypedResults.Ok(RecipientResponse.From(archived));
+ })
+ .WithName("ArchiveRecipient")
+ .WithSummary("Stops deliveries to a recipient from a date. Their history stays.");
+
+ recipients.MapPost(
+ "{id:guid}/unarchive",
+ async (Guid id, UnarchiveRecipientHandler handler, CancellationToken cancellationToken) =>
+ TypedResults.Ok(RecipientResponse.From(await handler.HandleAsync(id, cancellationToken))))
+ .WithName("UnarchiveRecipient")
+ .WithSummary("Brings an archived recipient back.");
+
+ return api;
+ }
+}
diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs
new file mode 100644
index 0000000..cc447b5
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientRequests.cs
@@ -0,0 +1,13 @@
+namespace PureFreshMilk.Api.Features.Recipients;
+
+/// Request body for POST /api/v1/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.
+internal sealed record AddRecipientRequest(string? Name, int ExpectedMl, DateOnly? StartsOn);
+
+/// Request body for PUT /api/v1/recipients/{id} : every editable field.
+internal sealed record UpdateRecipientRequest(string? Name, int ExpectedMl, DateOnly StartsOn);
+
+/// Optional request body for POST /api/v1/recipients/{id}/archive .
+/// The first day without deliveries. Left out, it is today in the household's time zone.
+internal sealed record ArchiveRecipientRequest(DateOnly? ArchivedFrom);
diff --git a/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs
new file mode 100644
index 0000000..3ea7bcb
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Features/Recipients/RecipientResponse.cs
@@ -0,0 +1,13 @@
+using PureFreshMilk.Application.Recipients;
+
+namespace PureFreshMilk.Api.Features.Recipients;
+
+/// A recipient in API responses.
+/// The usual daily quantity, in whole millilitres.
+/// The first day a delivery is expected (YYYY-MM-DD ).
+/// The first day without deliveries, or null while the recipient is not archived.
+internal sealed record RecipientResponse(Guid Id, string Name, int ExpectedMl, DateOnly StartsOn, DateOnly? ArchivedFrom)
+{
+ public static RecipientResponse From(RecipientDetails recipient) =>
+ new(recipient.Id, recipient.Name, recipient.ExpectedMl, recipient.StartsOn, recipient.ArchivedFrom);
+}
diff --git a/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs b/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs
new file mode 100644
index 0000000..5ddbd51
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Hosting/ApiRoutes.cs
@@ -0,0 +1,7 @@
+namespace PureFreshMilk.Api.Hosting;
+
+internal static class ApiRoutes
+{
+ /// The versioned prefix of every API route. A breaking change gets a new version group.
+ public const string Prefix = "/api/v1";
+}
diff --git a/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs b/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs
new file mode 100644
index 0000000..64a68ca
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Hosting/ApplicationExceptionHandler.cs
@@ -0,0 +1,66 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Domain.Common;
+
+namespace PureFreshMilk.Api.Hosting;
+
+///
+/// Turns the exceptions that use cases raise on purpose into RFC 9457 Problem Details:
+/// a broken rule is 400 (with the field it concerns, when there is one), something missing is 404,
+/// and a clash with stored data is 409. Both 404 and 409 carry a stable code that clients can branch on.
+/// Anything else is left to the default handler, which answers 500 without details.
+///
+internal sealed class ApplicationExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
+ {
+ var problem = exception switch
+ {
+ DomainException { Field: { } field } domain => new ValidationProblemDetails(
+ new Dictionary { [field] = [domain.Message] })
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "One or more validation errors occurred.",
+ Extensions = { ["code"] = "validation-failed" },
+ },
+ DomainException domain => new ProblemDetails
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "A business rule was broken.",
+ Detail = domain.Message,
+ Extensions = { ["code"] = "rule-violated" },
+ },
+ NotFoundException notFound => new ProblemDetails
+ {
+ Status = StatusCodes.Status404NotFound,
+ Title = "Not found",
+ Detail = notFound.Message,
+ Extensions = { ["code"] = notFound.Code },
+ },
+ ConflictException conflict => new ProblemDetails
+ {
+ Status = StatusCodes.Status409Conflict,
+ Title = "Conflict",
+ Detail = conflict.Message,
+ Extensions = { ["code"] = conflict.Code },
+ },
+ _ => null,
+ };
+
+ if (problem is null)
+ {
+ return false;
+ }
+
+ httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError;
+
+ return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
+ {
+ HttpContext = httpContext,
+ Exception = exception,
+ ProblemDetails = problem,
+ });
+ }
+}
diff --git a/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs b/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs
new file mode 100644
index 0000000..1204c8d
--- /dev/null
+++ b/backend/src/PureFreshMilk.Api/Hosting/UnauthenticatedAccess.cs
@@ -0,0 +1,29 @@
+namespace PureFreshMilk.Api.Hosting;
+
+///
+/// Until authentication exists the data endpoints read and write a household without a login, so they are
+/// switched on only where that is safe: Development and Testing, or when Access:AllowUnauthenticated
+/// (environment variable Access__AllowUnauthenticated ) is set to true on purpose (ADR 0004).
+///
+internal static class UnauthenticatedAccess
+{
+ public const string SettingName = "Access:AllowUnauthenticated";
+
+ public const string TestingEnvironment = "Testing";
+
+ /// Whether the data endpoints are allowed to run without a login in this environment.
+ public static bool IsAllowed(IHostEnvironment environment, IConfiguration configuration) =>
+ IsLocalEnvironment(environment) || IsSwitchedOn(configuration);
+
+ /// Whether the data endpoints are on only because the setting was turned on explicitly.
+ public static bool IsExplicitOptIn(IHostEnvironment environment, IConfiguration configuration) =>
+ !IsLocalEnvironment(environment) && IsSwitchedOn(configuration);
+
+ private static bool IsLocalEnvironment(IHostEnvironment environment) =>
+ environment.IsDevelopment() || environment.IsEnvironment(TestingEnvironment);
+
+ // Only an exact "true" counts. An empty or misspelled value leaves the endpoints off instead of
+ // stopping the API from starting.
+ private static bool IsSwitchedOn(IConfiguration configuration) =>
+ bool.TryParse(configuration[SettingName], out var switchedOn) && switchedOn;
+}
diff --git a/backend/src/PureFreshMilk.Api/Program.cs b/backend/src/PureFreshMilk.Api/Program.cs
index 92f45ab..b3a679a 100644
--- a/backend/src/PureFreshMilk.Api/Program.cs
+++ b/backend/src/PureFreshMilk.Api/Program.cs
@@ -1,3 +1,6 @@
+using PureFreshMilk.Api.Features.Households;
+using PureFreshMilk.Api.Features.MilkRates;
+using PureFreshMilk.Api.Features.Recipients;
using PureFreshMilk.Api.Features.Status;
using PureFreshMilk.Api.Hosting;
using PureFreshMilk.Application;
@@ -6,6 +9,7 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
+builder.Services.AddExceptionHandler();
builder.Services.AddOpenApi();
builder.Services.AddFrontendCors(builder.Configuration);
@@ -25,7 +29,23 @@
app.MapHealthEndpoints();
-var api = app.MapGroup("/api/v1");
+var api = app.MapGroup(ApiRoutes.Prefix);
api.MapStatusEndpoints();
+// These endpoints read and write a household without a login, so they exist only where that is safe.
+if (UnauthenticatedAccess.IsAllowed(app.Environment, app.Configuration))
+{
+ if (UnauthenticatedAccess.IsExplicitOptIn(app.Environment, app.Configuration))
+ {
+ app.Logger.LogWarning(
+ "{Setting} is true: the data endpoints are open to anyone who can reach this API. " +
+ "Do not expose it to a network you do not trust until authentication exists.",
+ UnauthenticatedAccess.SettingName);
+ }
+
+ api.MapHouseholdEndpoints();
+ api.MapRecipientEndpoints();
+ api.MapMilkRateEndpoints();
+}
+
app.Run();
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..4954210
--- /dev/null
+++ b/backend/src/PureFreshMilk.Application/Recipients/ArchiveRecipientHandler.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using PureFreshMilk.Application.Common;
+using PureFreshMilk.Application.Households;
+using PureFreshMilk.Application.Persistence;
+using PureFreshMilk.Domain.Recipients;
+
+namespace PureFreshMilk.Application.Recipients;
+
+///
+/// The first day without deliveries. Left out, it is today in the household's time zone, or the day after the
+/// recipient starts if that is later (a recipient is active for at least one day, so one who starts today
+/// cannot be archived from today).
+///
+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 ?? DefaultArchiveDate(recipient, household.Today(now)), now);
+ await unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return RecipientDetails.From(recipient);
+ }
+
+ private static DateOnly DefaultArchiveDate(Recipient recipient, DateOnly today)
+ {
+ // Not past the last representable day: the domain then rejects the date with its own message.
+ var dayAfterStart = recipient.StartsOn == DateOnly.MaxValue ? recipient.StartsOn : recipient.StartsOn.AddDays(1);
+
+ return dayAfterStart > today ? dayAfterStart : today;
+ }
+}
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/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..0ed2798
--- /dev/null
+++ b/backend/src/PureFreshMilk.Domain/Recipients/Recipient.cs
@@ -0,0 +1,130 @@
+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);
+
+ ///
+ /// 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)
+ {
+ 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 = NormalizeName(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/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs b/backend/src/PureFreshMilk.Infrastructure/Households/SingleHouseholdProvider.cs
new file mode 100644
index 0000000..825d6a3
--- /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.OrderBy(candidate => candidate.Id).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.Api.Tests/ApiJson.cs b/backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs
new file mode 100644
index 0000000..ecaac33
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Api.Tests/ApiJson.cs
@@ -0,0 +1,48 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace PureFreshMilk.Api.Tests;
+
+/// Small helpers for reading what the API sends back and for the household every data test needs.
+internal static class ApiJson
+{
+ public static async Task ReadAsync(this HttpResponseMessage response) =>
+ await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+
+ /// Asserts a Problem Details response and returns its body.
+ public static async Task ReadProblemAsync(this HttpResponseMessage response, HttpStatusCode expected)
+ {
+ Assert.Equal(expected, response.StatusCode);
+ Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
+
+ var problem = await response.ReadAsync();
+ Assert.Equal((int)expected, problem.GetProperty("status").GetInt32());
+ return problem;
+ }
+
+ /// The validation messages for one field of a 400 response.
+ public static string[] ErrorsFor(this JsonElement problem, string field) =>
+ [.. problem.GetProperty("errors").GetProperty(field).EnumerateArray().Select(message => message.GetString()!)];
+
+ public static async Task SetUpHouseholdAsync(this HttpClient client, string timeZoneId = "Asia/Kolkata") =>
+ await client.PutAsJsonAsync(
+ "/api/v1/household", new { name = "Example household", timeZoneId }, TestContext.Current.CancellationToken);
+
+ public static async Task AddRecipientAsync(
+ this HttpClient client, string name, int expectedMl = 1000, string? startsOn = null)
+ {
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/recipients", new { name, expectedMl, startsOn }, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ return await response.ReadAsync();
+ }
+
+ public static async Task AddRateAsync(this HttpClient client, string effectiveFrom, long pricePerLitrePaise)
+ {
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/milk-rates", new { effectiveFrom, pricePerLitrePaise }, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ return await response.ReadAsync();
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs
new file mode 100644
index 0000000..6e31472
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Households/HouseholdEndpointTests.cs
@@ -0,0 +1,111 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using PureFreshMilk.TestSupport;
+
+namespace PureFreshMilk.Api.Tests.Features.Households;
+
+public sealed class HouseholdEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private HttpClient Start()
+ {
+ factory.Database.Clear();
+ return factory.CreateClient();
+ }
+
+ [Fact]
+ public async Task Reading_before_set_up_is_404_with_a_code_clients_can_branch_on()
+ {
+ using var client = Start();
+
+ var response = await client.GetAsync("/api/v1/household", Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.NotFound);
+ Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Setting_up_creates_the_household_then_updates_it()
+ {
+ using var client = Start();
+
+ var created = await client.SetUpHouseholdAsync();
+
+ Assert.Equal(HttpStatusCode.Created, created.StatusCode);
+ Assert.Equal("/api/v1/household", created.Headers.Location?.ToString());
+ Assert.Equal("application/json", created.Content.Headers.ContentType?.MediaType);
+ var body = await created.ReadAsync();
+ Assert.Equal("Example household", body.GetProperty("name").GetString());
+ Assert.Equal("Asia/Kolkata", body.GetProperty("timeZoneId").GetString());
+ Assert.Equal("2026-10-30", body.GetProperty("today").GetString());
+ Assert.True(body.GetProperty("id").TryGetGuid(out var id));
+
+ var updated = await client.PutAsJsonAsync("/api/v1/household", new { name = "Renamed" }, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
+ var updatedBody = await updated.ReadAsync();
+ Assert.Equal(id, updatedBody.GetProperty("id").GetGuid());
+ Assert.Equal("Renamed", updatedBody.GetProperty("name").GetString());
+ Assert.Equal("Asia/Kolkata", updatedBody.GetProperty("timeZoneId").GetString());
+
+ var read = await (await client.GetAsync("/api/v1/household", Ct)).ReadAsync();
+ Assert.Equal("Renamed", read.GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task Today_is_the_date_in_the_household_time_zone()
+ {
+ factory.Database.Clear();
+ var atMidnightInKolkata = new DateTimeOffset(2026, 10, 30, 18, 30, 0, TimeSpan.Zero);
+ using var client = factory
+ .WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
+ services.AddSingleton(new FixedTimeProvider(atMidnightInKolkata))))
+ .CreateClient();
+
+ var body = await (await client.SetUpHouseholdAsync()).ReadAsync();
+
+ Assert.Equal("2026-10-31", body.GetProperty("today").GetString());
+ }
+
+ [Theory]
+ [InlineData("{}", "name")]
+ [InlineData("""{"name": " "}""", "name")]
+ [InlineData("""{"name": "Home", "timeZoneId": "Mars/Olympus_Mons"}""", "timeZoneId")]
+ [InlineData("""{"name": "Home", "timeZoneId": "India Standard Time"}""", "timeZoneId")]
+ public async Task Invalid_input_is_400_naming_the_field(string json, string field)
+ {
+ using var client = Start();
+
+ var response = await client.PutAsync("/api/v1/household", Json(json), Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.Equal("validation-failed", problem.GetProperty("code").GetString());
+ Assert.NotEmpty(problem.ErrorsFor(field));
+ Assert.Empty(factory.Database.Households);
+ }
+
+ [Fact]
+ public async Task Malformed_json_is_400_problem_details()
+ {
+ using var client = Start();
+
+ var response = await client.PutAsync("/api/v1/household", Json("{ not json"), Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task A_missing_body_is_400_problem_details()
+ {
+ using var client = Start();
+
+ var response = await client.PutAsync("/api/v1/household", content: null, Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ }
+
+ private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json");
+}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs
new file mode 100644
index 0000000..867005f
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Api.Tests/Features/MilkRates/MilkRateEndpointTests.cs
@@ -0,0 +1,219 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+
+namespace PureFreshMilk.Api.Tests.Features.MilkRates;
+
+public sealed class MilkRateEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private async Task StartWithHouseholdAsync()
+ {
+ factory.Database.Clear();
+ var client = factory.CreateClient();
+ await client.SetUpHouseholdAsync();
+ return client;
+ }
+
+ [Fact]
+ public async Task Listing_before_the_household_is_set_up_is_404()
+ {
+ factory.Database.Clear();
+ using var client = factory.CreateClient();
+
+ var problem = await (await client.GetAsync("/api/v1/milk-rates", Ct)).ReadProblemAsync(HttpStatusCode.NotFound);
+
+ Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Adding_returns_201_with_a_location_and_the_rate()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/milk-rates", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6250 }, Ct);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var body = await response.ReadAsync();
+ Assert.Equal($"/api/v1/milk-rates/{body.GetProperty("id").GetGuid()}", response.Headers.Location?.ToString());
+ Assert.Equal("2026-10-01", body.GetProperty("effectiveFrom").GetString());
+ Assert.Equal(6250, body.GetProperty("pricePerLitrePaise").GetInt64());
+ }
+
+ [Fact]
+ public async Task A_rate_can_start_in_the_past()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var body = await client.AddRateAsync("2020-01-01", 4000);
+
+ Assert.Equal("2020-01-01", body.GetProperty("effectiveFrom").GetString());
+ }
+
+ [Fact]
+ public async Task The_list_is_an_object_with_items_newest_first()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRateAsync("2026-10-15", 6400);
+ await client.AddRateAsync("2026-09-01", 5500);
+ await client.AddRateAsync("2026-10-01", 6000);
+
+ var body = await (await client.GetAsync("/api/v1/milk-rates", Ct)).ReadAsync();
+
+ var dates = body.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("effectiveFrom").GetString());
+ Assert.Equal(["2026-10-15", "2026-10-01", "2026-09-01"], dates);
+ }
+
+ [Theory]
+ [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 0}""", "pricePerLitrePaise")]
+ [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": -5}""", "pricePerLitrePaise")]
+ [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 1000001}""", "pricePerLitrePaise")]
+ [InlineData("""{"effectiveFrom": "2026-10-01"}""", "pricePerLitrePaise")]
+ [InlineData("""{"pricePerLitrePaise": 6250}""", "effectiveFrom")]
+ public async Task Adding_invalid_input_is_400_naming_the_field(string json, string field)
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsync("/api/v1/milk-rates", Json(json), Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.Equal("validation-failed", problem.GetProperty("code").GetString());
+ Assert.NotEmpty(problem.ErrorsFor(field));
+ Assert.Empty(factory.Database.MilkRates);
+ }
+
+ [Theory]
+ [InlineData("""{"effectiveFrom": "yesterday", "pricePerLitrePaise": 6250}""")]
+ [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": 62.5}""")]
+ [InlineData("""{"effectiveFrom": "2026-10-01", "pricePerLitrePaise": "₹62.50"}""")]
+ public async Task Adding_the_wrong_kind_of_value_is_400_problem_details(string json)
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsync("/api/v1/milk-rates", Json(json), Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task A_second_rate_for_the_same_day_is_409()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRateAsync("2026-10-01", 6000);
+
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/milk-rates", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6400 }, Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("rate-date-taken", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task The_rate_in_effect_on_a_date_is_the_latest_one_that_has_started()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRateAsync("2026-10-01", 6000);
+ await client.AddRateAsync("2026-10-15", 6400);
+
+ var before = await (await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-10-14", Ct)).ReadAsync();
+ var onTheDay = await (await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-10-15", Ct)).ReadAsync();
+ var today = await (await client.GetAsync("/api/v1/milk-rates/in-effect", Ct)).ReadAsync();
+
+ Assert.Equal(6000, before.GetProperty("pricePerLitrePaise").GetInt64());
+ Assert.Equal(6400, onTheDay.GetProperty("pricePerLitrePaise").GetInt64());
+ Assert.Equal(6400, today.GetProperty("pricePerLitrePaise").GetInt64()); // today is 30 October, after both start dates
+ }
+
+ [Fact]
+ [Trait("Invariant", "T-5")]
+ public async Task With_no_rate_in_force_the_answer_is_404_not_a_zero_price()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRateAsync("2026-10-01", 6000);
+
+ var empty = await client.GetAsync("/api/v1/milk-rates/in-effect?on=2026-09-30", Ct);
+
+ var problem = await empty.ReadProblemAsync(HttpStatusCode.NotFound);
+ Assert.Equal("no-rate-in-effect", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task A_malformed_date_in_the_query_is_400()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.GetAsync("/api/v1/milk-rates/in-effect?on=tomorrow", Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Updating_changes_the_date_and_price()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRateAsync("2026-10-01", 6000);
+ var id = added.GetProperty("id").GetGuid();
+
+ var response = await client.PutAsJsonAsync(
+ $"/api/v1/milk-rates/{id}", new { effectiveFrom = "2026-10-05", pricePerLitrePaise = 6400 }, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.ReadAsync();
+ Assert.Equal(id, body.GetProperty("id").GetGuid());
+ Assert.Equal("2026-10-05", body.GetProperty("effectiveFrom").GetString());
+ Assert.Equal(6400, body.GetProperty("pricePerLitrePaise").GetInt64());
+ }
+
+ [Fact]
+ public async Task Moving_a_rate_onto_the_day_of_another_is_409()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRateAsync("2026-10-01", 6000);
+ var other = await client.AddRateAsync("2026-10-15", 6400);
+
+ var response = await client.PutAsJsonAsync(
+ $"/api/v1/milk-rates/{other.GetProperty("id").GetGuid()}",
+ new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 7000 },
+ Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("rate-date-taken", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Updating_with_an_invalid_price_is_400_and_an_unknown_rate_is_404()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRateAsync("2026-10-01", 6000);
+
+ var invalid = await client.PutAsJsonAsync(
+ $"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}",
+ new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 0 },
+ Ct);
+ var unknown = await client.PutAsJsonAsync(
+ $"/api/v1/milk-rates/{Guid.CreateVersion7()}", new { effectiveFrom = "2026-10-01", pricePerLitrePaise = 6000 }, Ct);
+
+ var problem = await invalid.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.NotEmpty(problem.ErrorsFor("pricePerLitrePaise"));
+ var notFound = await unknown.ReadProblemAsync(HttpStatusCode.NotFound);
+ Assert.Equal("rate-not-found", notFound.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Deleting_returns_204_and_an_unknown_rate_is_404()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRateAsync("2026-10-01", 6000);
+
+ var deleted = await client.DeleteAsync($"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}", Ct);
+ var again = await client.DeleteAsync($"/api/v1/milk-rates/{added.GetProperty("id").GetGuid()}", Ct);
+
+ Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
+ Assert.Empty(factory.Database.MilkRates);
+ await again.ReadProblemAsync(HttpStatusCode.NotFound);
+ }
+
+ private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json");
+}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs
new file mode 100644
index 0000000..2ced6d8
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Api.Tests/Features/Recipients/RecipientEndpointTests.cs
@@ -0,0 +1,328 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+
+namespace PureFreshMilk.Api.Tests.Features.Recipients;
+
+public sealed class RecipientEndpointTests(PureFreshMilkApiFactory factory) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private async Task StartWithHouseholdAsync()
+ {
+ factory.Database.Clear();
+ var client = factory.CreateClient();
+ await client.SetUpHouseholdAsync();
+ return client;
+ }
+
+ [Fact]
+ public async Task Listing_before_the_household_is_set_up_is_404()
+ {
+ factory.Database.Clear();
+ using var client = factory.CreateClient();
+
+ var problem = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadProblemAsync(HttpStatusCode.NotFound);
+
+ Assert.Equal("household-not-set-up", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Adding_returns_201_with_a_location_and_the_recipient()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/recipients", new { name = " Recipient A ", expectedMl = 1000 }, Ct);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
+ var body = await response.ReadAsync();
+ var id = body.GetProperty("id").GetGuid();
+ Assert.Equal($"/api/v1/recipients/{id}", response.Headers.Location?.ToString());
+ Assert.Equal("Recipient A", body.GetProperty("name").GetString());
+ Assert.Equal(1000, body.GetProperty("expectedMl").GetInt32());
+ Assert.Equal("2026-10-30", body.GetProperty("startsOn").GetString()); // defaults to today
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, body.GetProperty("archivedFrom").ValueKind);
+ }
+
+ [Fact]
+ public async Task The_list_is_an_object_with_items_ordered_by_name()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRecipientAsync("Recipient B", 500, "2026-10-01");
+ await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01");
+
+ var body = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync();
+
+ var names = body.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("name").GetString());
+ Assert.Equal(["Recipient A", "Recipient B"], names);
+ }
+
+ [Fact]
+ public async Task An_empty_household_lists_no_recipients()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var body = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync();
+
+ Assert.Equal(0, body.GetProperty("items").GetArrayLength());
+ }
+
+ [Theory]
+ [InlineData("""{"expectedMl": 500}""", "name")]
+ [InlineData("""{"name": " ", "expectedMl": 500}""", "name")]
+ [InlineData("""{"name": "Recipient A"}""", "expectedMl")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 0}""", "expectedMl")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": -250}""", "expectedMl")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 20001}""", "expectedMl")]
+ public async Task Adding_invalid_input_is_400_naming_the_field(string json, string field)
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsync("/api/v1/recipients", Json(json), Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.Equal("validation-failed", problem.GetProperty("code").GetString());
+ Assert.NotEmpty(problem.ErrorsFor(field));
+ Assert.Empty(factory.Database.Recipients);
+ }
+
+ [Theory]
+ [InlineData("""{"name": "Recipient A", "expectedMl": "plenty"}""")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 500, "startsOn": "30/10/2026"}""")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 500.5}""")]
+ [InlineData("{ not json")]
+ public async Task Adding_the_wrong_kind_of_value_is_400_problem_details(string json)
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PostAsync("/api/v1/recipients", Json(json), Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Adding_a_duplicate_name_is_409_whatever_its_case()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRecipientAsync("Recipient A");
+
+ var response = await client.PostAsJsonAsync("/api/v1/recipients", new { name = "recipient a", expectedMl = 500 }, Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task A_recipient_can_be_read_by_id()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", 750, "2026-10-01");
+
+ var body = await (await client.GetAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Ct)).ReadAsync();
+
+ Assert.Equal("Recipient A", body.GetProperty("name").GetString());
+ Assert.Equal(750, body.GetProperty("expectedMl").GetInt32());
+ Assert.Equal("2026-10-01", body.GetProperty("startsOn").GetString());
+ }
+
+ [Fact]
+ public async Task An_unknown_recipient_is_404_and_a_malformed_id_is_not_a_route()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var unknown = await client.GetAsync($"/api/v1/recipients/{Guid.CreateVersion7()}", Ct);
+ var problem = await unknown.ReadProblemAsync(HttpStatusCode.NotFound);
+ Assert.Equal("recipient-not-found", problem.GetProperty("code").GetString());
+
+ var malformed = await client.GetAsync("/api/v1/recipients/not-a-guid", Ct);
+ Assert.Equal(HttpStatusCode.NotFound, malformed.StatusCode);
+ }
+
+ [Fact]
+ public async Task Updating_replaces_the_editable_fields()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01");
+ var id = added.GetProperty("id").GetGuid();
+
+ var response = await client.PutAsJsonAsync(
+ $"/api/v1/recipients/{id}", new { name = "Recipient Z", expectedMl = 500, startsOn = "2026-09-15" }, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.ReadAsync();
+ Assert.Equal(id, body.GetProperty("id").GetGuid());
+ Assert.Equal("Recipient Z", body.GetProperty("name").GetString());
+ Assert.Equal(500, body.GetProperty("expectedMl").GetInt32());
+ Assert.Equal("2026-09-15", body.GetProperty("startsOn").GetString());
+ }
+
+ [Theory]
+ [InlineData("""{"name": "", "expectedMl": 500, "startsOn": "2026-10-01"}""", "name")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 0, "startsOn": "2026-10-01"}""", "expectedMl")]
+ [InlineData("""{"name": "Recipient A", "expectedMl": 500}""", "startsOn")]
+ public async Task Updating_with_invalid_input_is_400_naming_the_field(string json, string field)
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", 1000, "2026-10-01");
+
+ var response = await client.PutAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Json(json), Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.NotEmpty(problem.ErrorsFor(field));
+ }
+
+ [Fact]
+ public async Task Renaming_onto_another_active_recipient_is_409()
+ {
+ using var client = await StartWithHouseholdAsync();
+ await client.AddRecipientAsync("Recipient A");
+ var other = await client.AddRecipientAsync("Recipient B");
+
+ var response = await client.PutAsJsonAsync(
+ $"/api/v1/recipients/{other.GetProperty("id").GetGuid()}",
+ new { name = "Recipient A", expectedMl = 500, startsOn = "2026-10-30" },
+ Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Updating_an_unknown_recipient_is_404()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var response = await client.PutAsJsonAsync(
+ $"/api/v1/recipients/{Guid.CreateVersion7()}", new { name = "Recipient A", expectedMl = 500, startsOn = "2026-10-30" }, Ct);
+
+ await response.ReadProblemAsync(HttpStatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task Archiving_without_a_body_archives_from_today()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+
+ var response = await client.PostAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", content: null, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("2026-10-30", (await response.ReadAsync()).GetProperty("archivedFrom").GetString());
+ }
+
+ [Fact]
+ public async Task Archiving_a_recipient_added_today_archives_from_tomorrow_because_they_are_active_for_a_day()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A"); // starts today, 30 October
+
+ var response = await client.PostAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", content: null, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("2026-10-31", (await response.ReadAsync()).GetProperty("archivedFrom").GetString());
+ }
+
+ [Fact]
+ public async Task Archiving_can_name_the_day()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+
+ var response = await client.PostAsJsonAsync(
+ $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", new { archivedFrom = "2026-11-15" }, Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("2026-11-15", (await response.ReadAsync()).GetProperty("archivedFrom").GetString());
+ }
+
+ [Fact]
+ public async Task Archiving_on_or_before_the_start_date_is_400_naming_the_field()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+
+ var response = await client.PostAsJsonAsync(
+ $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive", new { archivedFrom = "2026-10-01" }, Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.BadRequest);
+ Assert.NotEmpty(problem.ErrorsFor("archivedFrom"));
+ }
+
+ [Fact]
+ public async Task Archiving_twice_is_409()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+ var url = $"/api/v1/recipients/{added.GetProperty("id").GetGuid()}/archive";
+ await client.PostAsync(url, content: null, Ct);
+
+ var second = await client.PostAsync(url, content: null, Ct);
+
+ var problem = await second.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("recipient-already-archived", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Archived_recipients_are_hidden_unless_asked_for_and_can_be_restored()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var gone = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+ await client.AddRecipientAsync("Recipient B", startsOn: "2026-10-01");
+ var id = gone.GetProperty("id").GetGuid();
+ await client.PostAsync($"/api/v1/recipients/{id}/archive", content: null, Ct);
+
+ var visible = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync();
+ var everyone = await (await client.GetAsync("/api/v1/recipients?includeArchived=true", Ct)).ReadAsync();
+ Assert.Equal(1, visible.GetProperty("items").GetArrayLength());
+ Assert.Equal(2, everyone.GetProperty("items").GetArrayLength());
+
+ var restored = await client.PostAsync($"/api/v1/recipients/{id}/unarchive", content: null, Ct);
+ Assert.Equal(HttpStatusCode.OK, restored.StatusCode);
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, (await restored.ReadAsync()).GetProperty("archivedFrom").ValueKind);
+ var again = await (await client.GetAsync("/api/v1/recipients", Ct)).ReadAsync();
+ Assert.Equal(2, again.GetProperty("items").GetArrayLength());
+ }
+
+ [Fact]
+ public async Task Restoring_into_a_name_that_is_now_taken_is_409()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var first = await client.AddRecipientAsync("Recipient A", startsOn: "2026-10-01");
+ var id = first.GetProperty("id").GetGuid();
+ await client.PostAsync($"/api/v1/recipients/{id}/archive", content: null, Ct);
+ await client.AddRecipientAsync("Recipient A");
+
+ var response = await client.PostAsync($"/api/v1/recipients/{id}/unarchive", content: null, Ct);
+
+ var problem = await response.ReadProblemAsync(HttpStatusCode.Conflict);
+ Assert.Equal("recipient-name-taken", problem.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Archiving_or_restoring_an_unknown_recipient_is_404()
+ {
+ using var client = await StartWithHouseholdAsync();
+
+ var archive = await client.PostAsync($"/api/v1/recipients/{Guid.CreateVersion7()}/archive", content: null, Ct);
+ var unarchive = await client.PostAsync($"/api/v1/recipients/{Guid.CreateVersion7()}/unarchive", content: null, Ct);
+
+ await archive.ReadProblemAsync(HttpStatusCode.NotFound);
+ await unarchive.ReadProblemAsync(HttpStatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task There_is_no_way_to_delete_a_recipient()
+ {
+ using var client = await StartWithHouseholdAsync();
+ var added = await client.AddRecipientAsync("Recipient A");
+
+ var response = await client.DeleteAsync($"/api/v1/recipients/{added.GetProperty("id").GetGuid()}", Ct);
+
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+ Assert.Single(factory.Database.Recipients);
+ }
+
+ private static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json");
+}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs
index 4333edd..63c5cf3 100644
--- a/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs
+++ b/backend/tests/PureFreshMilk.Api.Tests/Features/StatusEndpointTests.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
+using PureFreshMilk.TestSupport;
namespace PureFreshMilk.Api.Tests.Features;
diff --git a/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs b/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs
deleted file mode 100644
index e2fd7eb..0000000
--- a/backend/tests/PureFreshMilk.Api.Tests/FixedTimeProvider.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace PureFreshMilk.Api.Tests;
-
-internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
-{
- public override DateTimeOffset GetUtcNow() => utcNow;
-}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs b/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs
new file mode 100644
index 0000000..90d8b99
--- /dev/null
+++ b/backend/tests/PureFreshMilk.Api.Tests/Hosting/UnauthenticatedAccessTests.cs
@@ -0,0 +1,100 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Hosting;
+
+namespace PureFreshMilk.Api.Tests.Hosting;
+
+///
+/// The data endpoints read and write a household without a login, so they exist only in Development and
+/// Testing, or when Access:AllowUnauthenticated is switched on explicitly (ADR 0004).
+///
+public sealed class UnauthenticatedAccessTests(PureFreshMilkApiFactory factory) : IClassFixture
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static readonly string[] DataRoutes =
+ [
+ "/api/v1/household",
+ "/api/v1/recipients",
+ "/api/v1/milk-rates",
+ ];
+
+ private HttpClient ClientFor(string environment, string? allowUnauthenticated = null)
+ {
+ // Each derived host gets its own in-memory database, so there is nothing to clear.
+ return factory
+ .WithWebHostBuilder(builder =>
+ {
+ builder.UseEnvironment(environment);
+ if (allowUnauthenticated is not null)
+ {
+ builder.UseSetting("Access:AllowUnauthenticated", allowUnauthenticated);
+ }
+ })
+ .CreateClient();
+ }
+
+ [Theory]
+ [InlineData("Production")]
+ [InlineData("Staging")]
+ public async Task Outside_development_and_testing_the_data_endpoints_are_not_mapped(string environment)
+ {
+ using var client = ClientFor(environment);
+
+ foreach (var route in DataRoutes)
+ {
+ var response = await client.GetAsync(route, Ct);
+ var problem = await response.ReadProblemAsync(HttpStatusCode.NotFound);
+
+ // A route that does not exist, not the "household not set up" answer of a route that does.
+ Assert.False(problem.TryGetProperty("code", out _), route);
+ }
+
+ var write = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct);
+ Assert.Equal(HttpStatusCode.NotFound, write.StatusCode);
+ }
+
+ [Fact]
+ public async Task Outside_development_and_testing_the_status_and_health_endpoints_still_work()
+ {
+ using var client = ClientFor("Production");
+
+ Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status", Ct)).StatusCode);
+ Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/health/live", Ct)).StatusCode);
+ }
+
+ [Theory]
+ [InlineData("false")]
+ [InlineData("")]
+ [InlineData("maybe")]
+ public async Task Anything_but_true_leaves_the_data_endpoints_off(string setting)
+ {
+ using var client = ClientFor("Production", setting);
+
+ var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task The_setting_turns_the_data_endpoints_on_outside_development()
+ {
+ using var client = ClientFor("Production", "true");
+
+ var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("Development")]
+ [InlineData("Testing")]
+ public async Task In_development_and_testing_the_data_endpoints_are_mapped(string environment)
+ {
+ using var client = ClientFor(environment);
+
+ var response = await client.PutAsJsonAsync("/api/v1/household", new { name = "Home" }, Ct);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ }
+}
diff --git a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj
index 95b9b19..d4272d7 100644
--- a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj
+++ b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilk.Api.Tests.csproj
@@ -2,6 +2,7 @@
+
diff --git a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs
index d70aed5..21b1180 100644
--- a/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs
+++ b/backend/tests/PureFreshMilk.Api.Tests/PureFreshMilkApiFactory.cs
@@ -1,24 +1,41 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using PureFreshMilk.TestSupport;
namespace PureFreshMilk.Api.Tests;
///
/// Hosts the API in memory. Runs in a dedicated "Testing" environment so that developer
/// user secrets and appsettings.Development.json never affect test results. The connection
-/// string points at a closed local port: these tests never need a running database.
+/// string points at a closed local port: these tests never need a running database. The
+/// repositories are replaced by in-memory ones, so the endpoints can be tested end to end
+/// without PostgreSQL, and the clock stands still at .
///
public sealed class PureFreshMilkApiFactory : WebApplicationFactory
{
public const string AllowedOrigin = "http://localhost:3000";
+ /// 06:00 UTC on 30 October 2026: 11:30 on the same day in Asia/Kolkata.
+ public static readonly DateTimeOffset StartTime = new(2026, 10, 30, 6, 0, 0, TimeSpan.Zero);
+
private const string UnreachableDatabase =
"Host=127.0.0.1;Port=1;Database=purefreshmilk_tests;Username=tests;Timeout=2";
+ /// The in-memory database behind the endpoints. Clear it at the start of a test that shares this host.
+ public InMemoryDatabase Database => Services.GetRequiredService();
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:PureFreshMilk", UnreachableDatabase);
builder.UseSetting("Cors:AllowedOrigins:0", AllowedOrigin);
+
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton(new FixedTimeProvider(StartTime));
+ services.UseInMemoryPersistence();
+ });
}
}
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