diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9d2d73a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+# Copy to .env and replace both values with local development secrets.
+# These values are required; no credentials or JWT signing key defaults are committed.
+MSSQL_SA_PASSWORD=
+Jwt__SigningKey=
diff --git a/backend/.config/dotnet-tools.json b/backend/.config/dotnet-tools.json
new file mode 100644
index 0000000..2fb3663
--- /dev/null
+++ b/backend/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "9.0.18",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..d761642
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,11 @@
+**/bin/
+**/obj/
+**/TestResults/
+tests/
+.git/
+.github/
+.env
+.env.*
+!*.env.example
+appsettings.Development.json
+appsettings.*.local.json
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
new file mode 100644
index 0000000..d62e07f
--- /dev/null
+++ b/backend/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ net9.0
+ enable
+ enable
+ latest
+ true
+ true
+
+
diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
new file mode 100644
index 0000000..7adcf0d
--- /dev/null
+++ b/backend/Directory.Packages.props
@@ -0,0 +1,30 @@
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..ff914bc
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,46 @@
+FROM mcr.microsoft.com/dotnet/sdk:9.0.316 AS base
+ARG APP_UID=10001
+ARG APP_GID=10001
+RUN groupadd --system --gid ${APP_GID} inventoryflow \
+ && useradd --uid ${APP_UID} --gid inventoryflow --create-home --home-dir /home/inventoryflow --shell /usr/sbin/nologin inventoryflow \
+ && mkdir -p /src /app /home/inventoryflow/.nuget/packages \
+ && chown -R inventoryflow:inventoryflow /src /app /home/inventoryflow
+ENV DOTNET_CLI_HOME=/home/inventoryflow \
+ NUGET_PACKAGES=/home/inventoryflow/.nuget/packages
+WORKDIR /src
+USER inventoryflow
+
+FROM base AS restore
+COPY --chown=inventoryflow:inventoryflow Directory.Build.props Directory.Packages.props InventoryFlow.sln ./
+COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Api/InventoryFlow.Api.csproj src/InventoryFlow.Api/
+COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Application/InventoryFlow.Application.csproj src/InventoryFlow.Application/
+COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Domain/InventoryFlow.Domain.csproj src/InventoryFlow.Domain/
+COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj src/InventoryFlow.Infrastructure/
+COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Shared/InventoryFlow.Shared.csproj src/InventoryFlow.Shared/
+RUN dotnet restore src/InventoryFlow.Api/InventoryFlow.Api.csproj
+
+FROM restore AS build
+COPY --chown=inventoryflow:inventoryflow src ./src
+RUN dotnet build src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration Release --no-restore
+
+FROM build AS publish
+RUN dotnet publish src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration Release --no-build --output /app/publish
+
+FROM mcr.microsoft.com/dotnet/aspnet:9.0.316 AS final
+ARG APP_UID=10001
+ARG APP_GID=10001
+RUN groupadd --system --gid ${APP_GID} inventoryflow \
+ && useradd --uid ${APP_UID} --gid inventoryflow --create-home --home-dir /home/inventoryflow --shell /usr/sbin/nologin inventoryflow \
+ && mkdir -p /app \
+ && chown inventoryflow:inventoryflow /app
+WORKDIR /app
+EXPOSE 8080
+ENV ASPNETCORE_URLS=http://+:8080
+COPY --from=publish --chown=inventoryflow:inventoryflow /app/publish .
+USER inventoryflow
+ENTRYPOINT ["dotnet", "InventoryFlow.Api.dll"]
+
+FROM build AS migrator
+COPY --chown=inventoryflow:inventoryflow .config .config
+RUN dotnet tool restore
+ENTRYPOINT ["dotnet", "ef", "database", "update", "--project", "src/InventoryFlow.Infrastructure", "--startup-project", "src/InventoryFlow.Api"]
diff --git a/backend/InventoryFlow.sln b/backend/InventoryFlow.sln
new file mode 100644
index 0000000..e68854a
--- /dev/null
+++ b/backend/InventoryFlow.sln
@@ -0,0 +1,129 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Domain", "src\InventoryFlow.Domain\InventoryFlow.Domain.csproj", "{D6637A17-757E-4C0E-82A8-4080C183B775}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Application", "src\InventoryFlow.Application\InventoryFlow.Application.csproj", "{3436993A-54EE-4E1C-A601-EDD2BEE33712}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Infrastructure", "src\InventoryFlow.Infrastructure\InventoryFlow.Infrastructure.csproj", "{20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Shared", "src\InventoryFlow.Shared\InventoryFlow.Shared.csproj", "{526EF02F-24EE-49E0-AC41-0F7174C5B961}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Api", "src\InventoryFlow.Api\InventoryFlow.Api.csproj", "{D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.UnitTests", "tests\InventoryFlow.UnitTests\InventoryFlow.UnitTests.csproj", "{A1976E8B-EC99-4F3F-A5C1-7B196893A477}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.IntegrationTests", "tests\InventoryFlow.IntegrationTests\InventoryFlow.IntegrationTests.csproj", "{5C4062B2-3592-421D-B956-DACAB8F01B42}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x64.Build.0 = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x86.Build.0 = Debug|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x64.ActiveCfg = Release|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x64.Build.0 = Release|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x86.ActiveCfg = Release|Any CPU
+ {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x86.Build.0 = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x64.Build.0 = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x86.Build.0 = Debug|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x64.ActiveCfg = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x64.Build.0 = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x86.ActiveCfg = Release|Any CPU
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x86.Build.0 = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x64.Build.0 = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x86.Build.0 = Debug|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x64.ActiveCfg = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x64.Build.0 = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x86.ActiveCfg = Release|Any CPU
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x86.Build.0 = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x64.Build.0 = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x86.Build.0 = Debug|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|Any CPU.Build.0 = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x64.ActiveCfg = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x64.Build.0 = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x86.ActiveCfg = Release|Any CPU
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x86.Build.0 = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x64.Build.0 = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x86.Build.0 = Debug|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x64.ActiveCfg = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x64.Build.0 = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x86.ActiveCfg = Release|Any CPU
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x86.Build.0 = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x64.Build.0 = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x86.Build.0 = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x64.Build.0 = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x86.Build.0 = Debug|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x64.ActiveCfg = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x64.Build.0 = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x86.ActiveCfg = Release|Any CPU
+ {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {D6637A17-757E-4C0E-82A8-4080C183B775} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {3436993A-54EE-4E1C-A601-EDD2BEE33712} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {526EF02F-24EE-49E0-AC41-0F7174C5B961} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {A1976E8B-EC99-4F3F-A5C1-7B196893A477} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {5C4062B2-3592-421D-B956-DACAB8F01B42} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..c346947
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,15 @@
+# Inventory Flow API
+
+Set the database connection string and JWT signing secret outside tracked configuration before running the API:
+
+```bash
+cd backend/src/InventoryFlow.Api
+dotnet user-secrets set "Jwt:SigningKey" "replace-with-at-least-32-random-bytes"
+export ConnectionStrings__InventoryFlowDatabase='Server=localhost,1433;Database=InventoryFlow;User Id=sa;Password=;TrustServerCertificate=True'
+```
+
+Production deployments must supply `Jwt__SigningKey` from a secret manager and set exact `Cors__AllowedOrigins__0` values. The browser refresh cookie is `HttpOnly`, `SameSite=Strict`, and requires same-site SPA/API deployment. Apply migrations with `dotnet ef database update --project src/InventoryFlow.Infrastructure --startup-project src/InventoryFlow.Api` from this directory.
+
+## Workspace tenancy
+
+Self-registration creates a personal workspace and immutable Owner membership. Authentication responses include that server-resolved workspace; workspace IDs are never accepted from the refresh cookie. Future multi-workspace switching and invitations are intentionally not yet implemented.
diff --git a/backend/src/InventoryFlow.Api/Controllers/AuthController.cs b/backend/src/InventoryFlow.Api/Controllers/AuthController.cs
new file mode 100644
index 0000000..d770990
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/AuthController.cs
@@ -0,0 +1,99 @@
+using System.Security.Claims;
+using InventoryFlow.Application.Features.Authentication;
+using InventoryFlow.Infrastructure.Authentication;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides browser authentication endpoints.
+[ApiController]
+[Route("api/auth")]
+public sealed class AuthController(ISender sender, IOptions options) : ControllerBase
+{
+ private readonly JwtOptions _options = options.Value;
+
+ /// Registers a user and starts a browser session.
+ [HttpPost("register")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> Register(RegisterUserCommand command, CancellationToken cancellationToken)
+ {
+ var session = await sender.Send(command, cancellationToken);
+ SetRefreshCookie(session.RefreshToken);
+ return Ok(session.Response);
+ }
+
+ /// Authenticates a user and starts a browser session.
+ [HttpPost("login")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task> Login(LoginUserCommand command, CancellationToken cancellationToken)
+ {
+ var session = await sender.Send(command, cancellationToken);
+ SetRefreshCookie(session.RefreshToken);
+ return Ok(session.Response);
+ }
+
+ /// Rotates the HttpOnly refresh cookie and returns a new access token.
+ [HttpPost("refresh")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task> Refresh(CancellationToken cancellationToken)
+ {
+ var refreshToken = Request.Cookies[_options.RefreshCookieName];
+ if (string.IsNullOrWhiteSpace(refreshToken)) { DeleteRefreshCookie(); return Unauthorized(); }
+ var session = await sender.Send(new RefreshSessionCommand(refreshToken), cancellationToken);
+ if (session is null) { DeleteRefreshCookie(); return Unauthorized(); }
+ SetRefreshCookie(session.RefreshToken);
+ return Ok(session.Response);
+ }
+
+ /// Switches the active workspace, rotating the persisted refresh session.
+ [Authorize]
+ [HttpPost("workspace/switch")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task> SwitchWorkspace(SwitchWorkspaceRequest request, CancellationToken cancellationToken)
+ {
+ var refreshToken = Request.Cookies[_options.RefreshCookieName];
+ if (string.IsNullOrWhiteSpace(refreshToken)) { DeleteRefreshCookie(); return Unauthorized(); }
+ var value = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (!Guid.TryParse(value, out var userId)) return Unauthorized();
+ var session = await sender.Send(new SwitchWorkspaceCommand(userId, request.WorkspaceId, refreshToken), cancellationToken);
+ if (session is null) return Forbid();
+ SetRefreshCookie(session.RefreshToken);
+ return Ok(session.Response);
+ }
+
+ /// Revokes the refresh family held by the browser.
+ [HttpPost("logout")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task Logout(CancellationToken cancellationToken)
+ {
+ await sender.Send(new LogoutSessionCommand(Request.Cookies[_options.RefreshCookieName]), cancellationToken);
+ DeleteRefreshCookie();
+ return NoContent();
+ }
+
+ /// Gets the current user from a valid bearer token.
+ [Authorize]
+ [HttpGet("me")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> Me(CancellationToken cancellationToken)
+ {
+ var value = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var workspaceValue = User.FindFirstValue(JwtAccessTokenIssuer.WorkspaceIdClaimType);
+ if (!Guid.TryParse(value, out var userId) || !Guid.TryParse(workspaceValue, out var workspaceId)) return Unauthorized();
+ var user = await sender.Send(new GetCurrentUserQuery(userId, workspaceId), cancellationToken);
+ return user is null ? Unauthorized() : Ok(user);
+ }
+
+ private void SetRefreshCookie(string value) => Response.Cookies.Append(_options.RefreshCookieName, value, new CookieOptions
+ { HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = !HttpContext.RequestServices.GetRequiredService().IsDevelopment(), Path = "/api/auth", MaxAge = TimeSpan.FromDays(_options.RefreshTokenLifetimeDays), Expires = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), IsEssential = true });
+ private void DeleteRefreshCookie() => Response.Cookies.Delete(_options.RefreshCookieName, new CookieOptions { Path = "/api/auth", SameSite = SameSiteMode.Strict, Secure = !HttpContext.RequestServices.GetRequiredService().IsDevelopment() });
+}
+
+/// Supplies an active-workspace switch request.
+public sealed record SwitchWorkspaceRequest(Guid WorkspaceId);
diff --git a/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs b/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs
new file mode 100644
index 0000000..ff3af13
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs
@@ -0,0 +1,81 @@
+using System.Security.Claims;
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Collaboration;
+using InventoryFlow.Domain.Entities;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides workspace collaboration endpoints.
+[ApiController]
+[Authorize]
+[Route("api/collaboration")]
+public sealed class CollaborationController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ /// Lists members in the active workspace. Owner-only.
+ [HttpGet("members")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ public async Task>> ListMembers(CancellationToken cancellationToken)
+ {
+ var workspace = await ResolveWorkspaceAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid();
+ return Ok(await sender.Send(new ListWorkspaceMembersQuery(workspace.Id, workspace.Role), cancellationToken));
+ }
+
+ /// Lists invitations in the active workspace. Owner-only.
+ [HttpGet("invitations")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ public async Task>> ListInvitations(CancellationToken cancellationToken)
+ {
+ var workspace = await ResolveWorkspaceAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid();
+ return Ok(await sender.Send(new ListWorkspaceInvitationsQuery(workspace.Id, workspace.Role), cancellationToken));
+ }
+
+ /// Creates a Member invitation for an existing registered email. Owner-only.
+ [HttpPost("invitations")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> CreateInvitation(CreateWorkspaceInvitationRequest request, CancellationToken cancellationToken)
+ {
+ var workspace = await ResolveWorkspaceAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid();
+ var userId = GetAuthenticatedUserId();
+ if (userId is null) return Unauthorized();
+ return Ok(await sender.Send(new CreateWorkspaceInvitationCommand(workspace.Id, userId.Value, workspace.Role, request.Email), cancellationToken));
+ }
+
+ /// Revokes a pending invitation. Owner-only.
+ [HttpPost("invitations/{invitationId:guid}/revoke")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task RevokeInvitation(Guid invitationId, CancellationToken cancellationToken)
+ {
+ var workspace = await ResolveWorkspaceAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid();
+ await sender.Send(new RevokeWorkspaceInvitationCommand(workspace.Id, invitationId, workspace.Role), cancellationToken);
+ return NoContent();
+ }
+
+ /// Accepts an invitation as the authenticated registered user.
+ [HttpPost("invitations/accept")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> AcceptInvitation(AcceptWorkspaceInvitationRequest request, CancellationToken cancellationToken)
+ {
+ var userId = GetAuthenticatedUserId();
+ if (userId is null) return Unauthorized();
+ return Ok(await sender.Send(new AcceptWorkspaceInvitationCommand(userId.Value, request.Token), cancellationToken));
+ }
+
+ private async Task ResolveWorkspaceAsync(CancellationToken cancellationToken) => await currentWorkspace.GetAsync(cancellationToken);
+
+ private Guid? GetAuthenticatedUserId()
+ {
+ var value = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ return Guid.TryParse(value, out var userId) ? userId : null;
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs b/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs
new file mode 100644
index 0000000..0a406db
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs
@@ -0,0 +1,57 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Inventory;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides workspace-scoped inventory receipt, issue, and balance endpoints.
+[ApiController]
+[Authorize]
+[Route("api/inventory")]
+public sealed class InventoryController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ /// Records a stock receipt exactly once for an idempotency key.
+ [HttpPost("receipts")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Receive(RecordInventoryMovementRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var movement = await sender.Send(new RecordReceiptCommand(workspace.Id, request.WarehouseId, request.ProductId,
+ request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken);
+ return movement is null ? NotFound() : Created($"/api/inventory/movements/{movement.Id}", movement);
+ }
+
+ /// Records a stock issue exactly once for an idempotency key.
+ [HttpPost("issues")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Issue(RecordInventoryMovementRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var movement = await sender.Send(new RecordIssueCommand(workspace.Id, request.WarehouseId, request.ProductId,
+ request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken);
+ return movement is null ? NotFound() : Created($"/api/inventory/movements/{movement.Id}", movement);
+ }
+
+ /// Lists current on-hand balances for the current workspace.
+ [HttpGet("balances")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> ListBalances(Guid? warehouseId, Guid? productId,
+ CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListInventoryBalancesQuery(workspace.Id, warehouseId, productId), cancellationToken));
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs b/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs
new file mode 100644
index 0000000..5ac48c7
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs
@@ -0,0 +1,50 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Products;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides workspace-scoped product catalog endpoints.
+[ApiController]
+[Authorize]
+[Route("api/products")]
+public sealed class ProductsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ /// Creates a product in the current workspace.
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> Create(CreateProductRequest request, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var product = await sender.Send(new CreateProductCommand(workspace.Id, request.Name, request.Sku), cancellationToken);
+ return Created($"/api/products/{product.Id}", product);
+ }
+
+ /// Lists active products in the current workspace.
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListProductsQuery(workspace.Id), cancellationToken));
+ }
+
+ /// Idempotently archives a product with no stock on hand.
+ [HttpDelete("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Archive(Guid id, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return await sender.Send(new ArchiveProductCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound();
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs b/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs
new file mode 100644
index 0000000..e8e1331
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs
@@ -0,0 +1,37 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Purchases;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/purchases/receipts")]
+public sealed class PurchaseReceiptsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Record(RecordPurchaseReceiptRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var receipt = await sender.Send(new RecordPurchaseReceiptCommand(workspace.Id, request.SupplierId, request.WarehouseId,
+ request.ProductId, request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken);
+ return receipt is null ? NotFound() : Created($"/api/purchases/receipts/{receipt.Id}", receipt);
+ }
+
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListPurchaseReceiptsQuery(workspace.Id), cancellationToken));
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs b/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs
new file mode 100644
index 0000000..3350f83
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs
@@ -0,0 +1,37 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Sales;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/sales/fulfillments")]
+public sealed class SalesFulfillmentsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Record(RecordSalesFulfillmentRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var fulfillment = await sender.Send(new RecordSalesFulfillmentCommand(workspace.Id, request.WarehouseId, request.ProductId,
+ request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken);
+ return fulfillment is null ? NotFound() : Created($"/api/sales/fulfillments/{fulfillment.Id}", fulfillment);
+ }
+
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListSalesFulfillmentsQuery(workspace.Id), cancellationToken));
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs b/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs
new file mode 100644
index 0000000..13b802a
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs
@@ -0,0 +1,49 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Suppliers;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides workspace-scoped supplier catalog endpoints.
+[ApiController]
+[Authorize]
+[Route("api/suppliers")]
+public sealed class SuppliersController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ /// Creates a supplier in the current workspace.
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> Create(CreateSupplierRequest request, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var supplier = await sender.Send(new CreateSupplierCommand(workspace.Id, request.Name), cancellationToken);
+ return Created($"/api/suppliers/{supplier.Id}", supplier);
+ }
+
+ /// Lists active suppliers in the current workspace.
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListSuppliersQuery(workspace.Id), cancellationToken));
+ }
+
+ /// Idempotently archives a supplier.
+ [HttpDelete("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Archive(Guid id, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return await sender.Send(new ArchiveSupplierCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound();
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs b/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs
new file mode 100644
index 0000000..848e98a
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs
@@ -0,0 +1,37 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Transfers;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/transfers")]
+public sealed class WarehouseTransfersController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Record(RecordWarehouseTransferRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var transfer = await sender.Send(new RecordWarehouseTransferCommand(workspace.Id, request.SourceWarehouseId,
+ request.DestinationWarehouseId, request.ProductId, request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken);
+ return transfer is null ? NotFound() : Created($"/api/transfers/{transfer.Id}", transfer);
+ }
+
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListWarehouseTransfersQuery(workspace.Id), cancellationToken));
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs b/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs
new file mode 100644
index 0000000..1e8d90a
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs
@@ -0,0 +1,50 @@
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Warehouses;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.Controllers;
+
+/// Provides workspace-scoped warehouse catalog endpoints.
+[ApiController]
+[Authorize]
+[Route("api/warehouses")]
+public sealed class WarehousesController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase
+{
+ /// Creates a warehouse in the current workspace.
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> Create(CreateWarehouseRequest request, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ var warehouse = await sender.Send(new CreateWarehouseCommand(workspace.Id, request.Name), cancellationToken);
+ return Created($"/api/warehouses/{warehouse.Id}", warehouse);
+ }
+
+ /// Lists active warehouses in the current workspace.
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return Ok(await sender.Send(new ListWarehousesQuery(workspace.Id), cancellationToken));
+ }
+
+ /// Idempotently archives a warehouse with no stock on hand.
+ [HttpDelete("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Archive(Guid id, CancellationToken cancellationToken)
+ {
+ var workspace = await currentWorkspace.GetAsync(cancellationToken);
+ if (workspace is null) return Forbid();
+ return await sender.Send(new ArchiveWarehouseCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound();
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..af8920c
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs
@@ -0,0 +1,95 @@
+using FluentValidation;
+using InventoryFlow.Application.Features.Authentication;
+using InventoryFlow.Application.Features.Collaboration;
+using InventoryFlow.Application.Features.Inventory;
+using InventoryFlow.Application.Features.Products;
+using InventoryFlow.Application.Features.Suppliers;
+using InventoryFlow.Application.Features.Warehouses;
+using InventoryFlow.Domain.Exceptions;
+using InventoryFlow.Domain.Entities;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InventoryFlow.Api.ExceptionHandling;
+
+///
+/// Converts unhandled application exceptions into RFC 7807 problem details responses.
+///
+/// The logger used to capture unexpected failures.
+public sealed class GlobalExceptionHandler(
+ ILogger logger) : IExceptionHandler
+{
+ ///
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ if (httpContext.Response.HasStarted)
+ {
+ logger.LogWarning(
+ exception,
+ "The response has already started for {RequestMethod} {RequestPath}",
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+
+ return false;
+ }
+
+ var statusCode = exception switch
+ {
+ ValidationException => StatusCodes.Status400BadRequest,
+ DomainException => StatusCodes.Status400BadRequest,
+ AuthenticationException => StatusCodes.Status401Unauthorized,
+ UnauthorizedAccessException => StatusCodes.Status403Forbidden,
+ ProductSkuConflictException => StatusCodes.Status409Conflict,
+ SupplierNameConflictException => StatusCodes.Status409Conflict,
+ WarehouseNameConflictException => StatusCodes.Status409Conflict,
+ InsufficientInventoryException => StatusCodes.Status409Conflict,
+ InventoryArchiveConflictException => StatusCodes.Status409Conflict,
+ CollaborationConflictException => StatusCodes.Status409Conflict,
+ _ => StatusCodes.Status500InternalServerError,
+ };
+
+ logger.LogError(
+ exception,
+ "Unhandled exception while processing {RequestMethod} {RequestPath}",
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+
+ if (exception is ValidationException validationException)
+ {
+ var validation = new ValidationProblemDetails(validationException.Errors.GroupBy(error => error.PropertyName).ToDictionary(group => group.Key, group => group.Select(error => error.ErrorMessage).ToArray()))
+ { Status = statusCode, Title = "One or more validation errors occurred.", Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1" };
+ httpContext.Response.StatusCode = statusCode;
+ await httpContext.Response.WriteAsJsonAsync(validation, options: null, contentType: "application/problem+json", cancellationToken: cancellationToken);
+ return true;
+ }
+
+ var problemDetails = new ProblemDetails
+ {
+ Status = statusCode,
+ Title = statusCode switch { StatusCodes.Status400BadRequest => "A business rule was violated.", StatusCodes.Status401Unauthorized => "Authentication failed.", StatusCodes.Status403Forbidden => "Access is forbidden.", StatusCodes.Status409Conflict => "A conflicting value or insufficient inventory exists.", _ => "An unexpected error occurred." },
+ Type = statusCode switch
+ {
+ StatusCodes.Status400BadRequest => "https://tools.ietf.org/html/rfc9110#section-15.5.1",
+ StatusCodes.Status401Unauthorized => "https://tools.ietf.org/html/rfc9110#section-15.5.2",
+ StatusCodes.Status403Forbidden => "https://tools.ietf.org/html/rfc9110#section-15.5.4",
+ StatusCodes.Status409Conflict => "https://tools.ietf.org/html/rfc9110#section-15.5.10",
+ _ => "https://tools.ietf.org/html/rfc9110#section-15.6.1",
+ },
+ Detail = exception is DomainException or InsufficientInventoryException or InventoryArchiveConflictException
+ ? exception.Message
+ : null,
+ };
+
+ httpContext.Response.StatusCode = statusCode;
+ await httpContext.Response.WriteAsJsonAsync(
+ problemDetails,
+ options: null,
+ contentType: "application/problem+json",
+ cancellationToken: cancellationToken);
+
+ return true;
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj b/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj
new file mode 100644
index 0000000..9a4b8e7
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj
@@ -0,0 +1,20 @@
+
+
+ inventory-flow-api-development
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/InventoryFlow.Api/InventoryFlow.Api.http b/backend/src/InventoryFlow.Api/InventoryFlow.Api.http
new file mode 100644
index 0000000..bab68c0
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/InventoryFlow.Api.http
@@ -0,0 +1,30 @@
+@InventoryFlow.Api_HostAddress = http://localhost:5255
+
+GET {{InventoryFlow.Api_HostAddress}}/health
+Accept: application/json
+
+###
+
+### Register (the API writes the refresh cookie; do not place one in request bodies)
+POST {{InventoryFlow.Api_HostAddress}}/api/auth/register
+Content-Type: application/json
+
+{
+ "displayName": "Example User",
+ "email": "example@example.test",
+ "password": "Example-password-123!"
+}
+
+### Current user (replace only in local scratch requests)
+GET {{InventoryFlow.Api_HostAddress}}/api/auth/me
+Authorization: Bearer {{$processEnv ACCESS_TOKEN}}
+
+### Registration returns the server-resolved personal workspace
+POST {{InventoryFlow.Api_HostAddress}}/api/auth/register
+Content-Type: application/json
+
+{
+ "displayName": "Workspace Owner",
+ "email": "owner@example.test",
+ "password": "Password!12345"
+}
diff --git a/backend/src/InventoryFlow.Api/Program.cs b/backend/src/InventoryFlow.Api/Program.cs
new file mode 100644
index 0000000..528ccd0
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Program.cs
@@ -0,0 +1,66 @@
+using System.Threading.RateLimiting;
+using InventoryFlow.Api.ExceptionHandling;
+using Microsoft.AspNetCore.RateLimiting;
+using InventoryFlow.Application.DependencyInjection;
+using InventoryFlow.Infrastructure.DependencyInjection;
+using Serilog;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Host.UseSerilog((context, services, loggerConfiguration) => loggerConfiguration
+ .ReadFrom.Configuration(context.Configuration)
+ .ReadFrom.Services(services)
+ .Enrich.FromLogContext());
+
+builder.Services.AddApplication();
+builder.Services.AddInfrastructure(builder.Configuration);
+builder.Services.AddControllers();
+builder.Services.AddAuthorization();
+builder.Services.AddOpenApi();
+builder.Services.AddProblemDetails();
+builder.Services.AddExceptionHandler();
+builder.Services.AddHealthChecks();
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.AddFixedWindowLimiter("api", limiterOptions =>
+ {
+ limiterOptions.PermitLimit = 100;
+ limiterOptions.Window = TimeSpan.FromMinutes(1);
+ limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ limiterOptions.QueueLimit = 0;
+ });
+});
+builder.Services.AddCors(options => options.AddPolicy("Frontend", policy =>
+{
+ var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? [];
+ policy.WithOrigins(allowedOrigins)
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+}));
+
+var app = builder.Build();
+
+app.UseExceptionHandler();
+app.UseSerilogRequestLogging();
+app.UseHttpsRedirection();
+app.UseRateLimiter();
+app.UseCors("Frontend");
+app.UseAuthentication();
+app.UseAuthorization();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.MapHealthChecks("/health").RequireRateLimiting("api");
+app.MapControllers().RequireRateLimiting("api");
+
+app.Run();
+
+///
+/// Exposes the generated top-level program class to integration tests.
+///
+public partial class Program;
diff --git a/backend/src/InventoryFlow.Api/Properties/launchSettings.json b/backend/src/InventoryFlow.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..a9db21b
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5255",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/backend/src/InventoryFlow.Api/appsettings.json b/backend/src/InventoryFlow.Api/appsettings.json
new file mode 100644
index 0000000..656e113
--- /dev/null
+++ b/backend/src/InventoryFlow.Api/appsettings.json
@@ -0,0 +1,33 @@
+{
+ "AllowedHosts": "*",
+ "Cors": {
+ "AllowedOrigins": [
+ "http://localhost:5173"
+ ]
+ },
+ "Jwt": {
+ "Issuer": "InventoryFlow",
+ "Audience": "InventoryFlow.Web",
+ "AccessTokenLifetimeMinutes": 10,
+ "RefreshTokenLifetimeDays": 7,
+ "RefreshCookieName": "inventory_flow_refresh"
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console"
+ }
+ ],
+ "Enrich": [ "FromLogContext" ],
+ "Properties": {
+ "Application": "InventoryFlow.Api"
+ }
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs
new file mode 100644
index 0000000..b3541b1
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using MediatR;
+
+namespace InventoryFlow.Application.Common.Behaviors;
+
+/// Runs FluentValidation before MediatR handlers.
+public sealed class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior
+ where TRequest : notnull
+{
+ ///
+ public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
+ {
+ var context = new ValidationContext(request);
+ var failures = (await Task.WhenAll(validators.Select(validator => validator.ValidateAsync(context, cancellationToken))))
+ .SelectMany(result => result.Errors).Where(error => error is not null).ToList();
+ if (failures.Count != 0) throw new ValidationException(failures);
+ return await next(cancellationToken);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs
new file mode 100644
index 0000000..0383256
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs
@@ -0,0 +1,17 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Common.Tenancy;
+
+/// Represents the server-resolved workspace for the current request.
+public sealed record CurrentWorkspace
+{
+ public CurrentWorkspace(Guid id, string name, WorkspaceMemberRole role)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ (Id, Name, Role) = (id, name, role);
+ }
+
+ public Guid Id { get; }
+ public string Name { get; }
+ public WorkspaceMemberRole Role { get; }
+}
diff --git a/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs b/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs
new file mode 100644
index 0000000..e26f190
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs
@@ -0,0 +1,8 @@
+namespace InventoryFlow.Application.Common.Tenancy;
+
+/// Resolves the authenticated request's unambiguous current workspace.
+public interface ICurrentWorkspace
+{
+ /// Returns the current workspace or null when unauthenticated, missing, or ambiguous.
+ Task GetAsync(CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs b/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs
new file mode 100644
index 0000000..11bbe22
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs
@@ -0,0 +1,32 @@
+using FluentValidation;
+using InventoryFlow.Application.Common.Behaviors;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace InventoryFlow.Application.DependencyInjection;
+
+///
+/// Registers application-layer services.
+///
+public static class ApplicationServiceCollectionExtensions
+{
+ ///
+ /// Adds MediatR handlers and FluentValidation validators from the application assembly.
+ ///
+ /// The dependency-injection service collection.
+ /// The supplied instance.
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ var assembly = typeof(ApplicationServiceCollectionExtensions).Assembly;
+ services.AddMediatR(configuration =>
+ {
+ configuration.RegisterServicesFromAssembly(assembly);
+ configuration.AddOpenBehavior(typeof(ValidationBehavior<,>));
+ });
+ services.AddValidatorsFromAssembly(assembly);
+
+ return services;
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs
new file mode 100644
index 0000000..10abf8e
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs
@@ -0,0 +1,31 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Authentication;
+
+/// Delegates registration to the authentication port.
+public sealed class RegisterUserHandler(IAuthenticationService service) : IRequestHandler
+{ public Task Handle(RegisterUserCommand request, CancellationToken cancellationToken) => service.RegisterAsync(request, cancellationToken); }
+/// Delegates login to the authentication port.
+public sealed class LoginUserHandler(IAuthenticationService service) : IRequestHandler
+{ public async Task Handle(LoginUserCommand request, CancellationToken cancellationToken) => await service.LoginAsync(request, cancellationToken) ?? throw new AuthenticationException(); }
+/// Delegates refresh to the authentication port.
+public sealed class RefreshSessionHandler(IAuthenticationService service) : IRequestHandler
+{ public Task Handle(RefreshSessionCommand request, CancellationToken cancellationToken) => service.RefreshAsync(request.RefreshToken, cancellationToken); }
+/// Delegates workspace switching to the authentication port.
+public sealed class SwitchWorkspaceHandler(IAuthenticationService service) : IRequestHandler
+{ public Task Handle(SwitchWorkspaceCommand request, CancellationToken cancellationToken) => service.SwitchWorkspaceAsync(request.UserId, request.WorkspaceId, request.RefreshToken, cancellationToken); }
+/// Delegates logout to the authentication port.
+public sealed class LogoutSessionHandler(IAuthenticationService service) : IRequestHandler
+{ public async Task Handle(LogoutSessionCommand request, CancellationToken cancellationToken) => await service.LogoutAsync(request.RefreshToken, cancellationToken); }
+/// Delegates current-user lookup to the authentication port.
+public sealed class GetCurrentUserHandler(IAuthenticationService service) : IRequestHandler
+{ public Task Handle(GetCurrentUserQuery request, CancellationToken cancellationToken) => service.GetUserAsync(request.UserId, request.WorkspaceId, cancellationToken); }
+
+/// Represents an externally-uniform authentication failure.
+public sealed class AuthenticationException : Exception
+{
+ /// Initializes the exception.
+ public AuthenticationException() : base("Authentication failed.")
+ {
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs
new file mode 100644
index 0000000..74b51d9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs
@@ -0,0 +1,26 @@
+namespace InventoryFlow.Application.Features.Authentication;
+
+/// Represents an authenticated workspace exposed to clients.
+public sealed record AuthenticatedWorkspace(Guid Id, string Name, string Role);
+
+/// Represents the authenticated user exposed to clients.
+public sealed record AuthenticatedUser(Guid Id, string Email, string DisplayName, AuthenticatedWorkspace Workspace, IReadOnlyCollection Workspaces);
+
+/// Represents a browser session response.
+public sealed record AuthenticationResponse(string AccessToken, DateTimeOffset AccessTokenExpiresAtUtc, AuthenticatedUser User);
+
+/// Represents the internal result used to write a refresh cookie.
+public sealed record AuthenticationSession(AuthenticationResponse Response, string RefreshToken);
+
+/// Supplies registration input.
+public sealed record RegisterUserCommand(string DisplayName, string Email, string Password) : MediatR.IRequest;
+/// Supplies login input.
+public sealed record LoginUserCommand(string Email, string Password) : MediatR.IRequest;
+/// Refreshes a browser session.
+public sealed record RefreshSessionCommand(string RefreshToken) : MediatR.IRequest;
+/// Switches the active workspace for the browser session.
+public sealed record SwitchWorkspaceCommand(Guid UserId, Guid WorkspaceId, string RefreshToken) : MediatR.IRequest;
+/// Ends a browser session.
+public sealed record LogoutSessionCommand(string? RefreshToken) : MediatR.IRequest;
+/// Retrieves the current user.
+public sealed record GetCurrentUserQuery(Guid UserId, Guid WorkspaceId) : MediatR.IRequest;
diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs
new file mode 100644
index 0000000..d9d7f5b
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs
@@ -0,0 +1,40 @@
+using FluentValidation;
+
+namespace InventoryFlow.Application.Features.Authentication;
+
+/// Validates registration input.
+public sealed class RegisterUserCommandValidator : AbstractValidator
+{
+ /// Initializes validation rules.
+ public RegisterUserCommandValidator()
+ {
+ RuleFor(command => command.DisplayName).NotEmpty().MaximumLength(200).Must(name => name is not null && name == name.Trim()).WithMessage("Display name cannot have leading or trailing whitespace.");
+ RuleFor(command => command.Email).NotEmpty().EmailAddress().MaximumLength(256);
+ RuleFor(command => command.Password).NotEmpty();
+ }
+}
+/// Validates login input.
+public sealed class LoginUserCommandValidator : AbstractValidator
+{ public LoginUserCommandValidator() { RuleFor(command => command.Email).NotEmpty().EmailAddress().MaximumLength(256); RuleFor(command => command.Password).NotEmpty(); } }
+/// Validates refresh input.
+public sealed class RefreshSessionCommandValidator : AbstractValidator
+{ public RefreshSessionCommandValidator() => RuleFor(command => command.RefreshToken).NotEmpty(); }
+/// Validates current-user lookup input.
+public sealed class GetCurrentUserQueryValidator : AbstractValidator
+{
+ public GetCurrentUserQueryValidator()
+ {
+ RuleFor(query => query.UserId).NotEmpty();
+ RuleFor(query => query.WorkspaceId).NotEmpty();
+ }
+}
+/// Validates workspace switch input.
+public sealed class SwitchWorkspaceCommandValidator : AbstractValidator
+{
+ public SwitchWorkspaceCommandValidator()
+ {
+ RuleFor(command => command.UserId).NotEmpty();
+ RuleFor(command => command.WorkspaceId).NotEmpty();
+ RuleFor(command => command.RefreshToken).NotEmpty();
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs b/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs
new file mode 100644
index 0000000..7b8ef83
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs
@@ -0,0 +1,18 @@
+namespace InventoryFlow.Application.Features.Authentication;
+
+/// Defines authentication operations implemented by infrastructure.
+public interface IAuthenticationService
+{
+ /// Registers a user and creates a session.
+ Task RegisterAsync(RegisterUserCommand command, CancellationToken cancellationToken);
+ /// Authenticates a user and creates a session.
+ Task LoginAsync(LoginUserCommand command, CancellationToken cancellationToken);
+ /// Rotates a refresh token.
+ Task RefreshAsync(string refreshToken, CancellationToken cancellationToken);
+ /// Switches the active workspace and rotates the refresh token.
+ Task SwitchWorkspaceAsync(Guid userId, Guid workspaceId, string refreshToken, CancellationToken cancellationToken);
+ /// Revokes the token family represented by a refresh token.
+ Task LogoutAsync(string? refreshToken, CancellationToken cancellationToken);
+ /// Gets a current user by identifier and active workspace.
+ Task GetUserAsync(Guid userId, Guid workspaceId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs
new file mode 100644
index 0000000..f1f52a9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs
@@ -0,0 +1,23 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Collaboration;
+
+/// Delegates member listing to the collaboration port.
+public sealed class ListWorkspaceMembersHandler(ICollaborationService service) : IRequestHandler>
+{ public Task> Handle(ListWorkspaceMembersQuery request, CancellationToken cancellationToken) => service.ListMembersAsync(request, cancellationToken); }
+
+/// Delegates invitation listing to the collaboration port.
+public sealed class ListWorkspaceInvitationsHandler(ICollaborationService service) : IRequestHandler>
+{ public Task> Handle(ListWorkspaceInvitationsQuery request, CancellationToken cancellationToken) => service.ListInvitationsAsync(request, cancellationToken); }
+
+/// Delegates invitation creation to the collaboration port.
+public sealed class CreateWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler
+{ public Task Handle(CreateWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.CreateInvitationAsync(request, cancellationToken); }
+
+/// Delegates invitation revocation to the collaboration port.
+public sealed class RevokeWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler
+{ public Task Handle(RevokeWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.RevokeInvitationAsync(request, cancellationToken); }
+
+/// Delegates invitation acceptance to the collaboration port.
+public sealed class AcceptWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler
+{ public Task Handle(AcceptWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.AcceptInvitationAsync(request, cancellationToken); }
diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs
new file mode 100644
index 0000000..5f65599
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs
@@ -0,0 +1,43 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Collaboration;
+
+/// Represents a workspace member returned to administrators.
+public sealed record WorkspaceMemberResponse(Guid UserId, string Email, string DisplayName, string Role, DateTimeOffset CreatedAtUtc);
+
+/// Represents a workspace invitation returned to administrators.
+public sealed record WorkspaceInvitationResponse(
+ Guid Id,
+ string Email,
+ string Role,
+ DateTimeOffset ExpiresAtUtc,
+ DateTimeOffset CreatedAtUtc,
+ Guid CreatedByUserId,
+ DateTimeOffset? AcceptedAtUtc,
+ Guid? AcceptedByUserId,
+ DateTimeOffset? RevokedAtUtc);
+
+/// Represents a newly-created invitation with its plaintext token shown once.
+public sealed record CreatedWorkspaceInvitationResponse(WorkspaceInvitationResponse Invitation, string Token);
+
+/// Represents a request to create an invitation.
+public sealed record CreateWorkspaceInvitationRequest(string Email);
+
+/// Represents a request to accept an invitation.
+public sealed record AcceptWorkspaceInvitationRequest(string Token);
+
+/// Lists members for the active workspace.
+public sealed record ListWorkspaceMembersQuery(Guid WorkspaceId, WorkspaceMemberRole CurrentUserRole) : IRequest>;
+
+/// Lists invitations for the active workspace.
+public sealed record ListWorkspaceInvitationsQuery(Guid WorkspaceId, WorkspaceMemberRole CurrentUserRole) : IRequest>;
+
+/// Creates a member invitation for the active workspace.
+public sealed record CreateWorkspaceInvitationCommand(Guid WorkspaceId, Guid CreatedByUserId, WorkspaceMemberRole CurrentUserRole, string Email) : IRequest;
+
+/// Revokes a pending invitation for the active workspace.
+public sealed record RevokeWorkspaceInvitationCommand(Guid WorkspaceId, Guid InvitationId, WorkspaceMemberRole CurrentUserRole) : IRequest;
+
+/// Accepts an invitation for the authenticated user.
+public sealed record AcceptWorkspaceInvitationCommand(Guid UserId, string Token) : IRequest;
diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs
new file mode 100644
index 0000000..f2e24df
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs
@@ -0,0 +1,23 @@
+namespace InventoryFlow.Application.Features.Collaboration;
+
+/// Defines workspace collaboration operations.
+public interface ICollaborationService
+{
+ /// Lists active workspace members.
+ Task> ListMembersAsync(ListWorkspaceMembersQuery query, CancellationToken cancellationToken);
+
+ /// Lists active workspace invitations.
+ Task> ListInvitationsAsync(ListWorkspaceInvitationsQuery query, CancellationToken cancellationToken);
+
+ /// Creates a member invitation.
+ Task CreateInvitationAsync(CreateWorkspaceInvitationCommand command, CancellationToken cancellationToken);
+
+ /// Revokes a pending invitation.
+ Task RevokeInvitationAsync(RevokeWorkspaceInvitationCommand command, CancellationToken cancellationToken);
+
+ /// Accepts an invitation.
+ Task AcceptInvitationAsync(AcceptWorkspaceInvitationCommand command, CancellationToken cancellationToken);
+}
+
+/// Represents a collaboration conflict.
+public sealed class CollaborationConflictException(string message) : Exception(message);
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs b/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs
new file mode 100644
index 0000000..4471b70
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs
@@ -0,0 +1,15 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Provides atomic workspace-scoped inventory ledger persistence.
+public interface IInventoryLedger
+{
+ /// Records a movement once for an idempotency key, returning null when its product or warehouse is unavailable.
+ Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type,
+ decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, CancellationToken cancellationToken);
+
+ /// Lists current balances, optionally restricted to a warehouse or product.
+ Task> ListBalancesAsync(Guid workspaceId, Guid? warehouseId, Guid? productId,
+ CancellationToken cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs
new file mode 100644
index 0000000..ce39d93
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs
@@ -0,0 +1,8 @@
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Indicates an inventory source cannot be archived while stock remains on hand.
+public sealed class InventoryArchiveConflictException : Exception
+{
+ /// Initializes the exception with a safe client-facing message.
+ public InventoryArchiveConflictException() : base("This product or warehouse cannot be archived while it has inventory on hand.") { }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs
new file mode 100644
index 0000000..85bf2d8
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs
@@ -0,0 +1,40 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Handles receipt recording.
+public sealed class RecordReceiptHandler(IInventoryLedger ledger, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(RecordReceiptCommand request, CancellationToken cancellationToken)
+ {
+ var movement = await ledger.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId,
+ InventoryMovementType.Receipt, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken);
+ return movement is null ? null : InventoryMovementResponse.From(movement);
+ }
+}
+
+/// Handles issue recording.
+public sealed class RecordIssueHandler(IInventoryLedger ledger, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(RecordIssueCommand request, CancellationToken cancellationToken)
+ {
+ var movement = await ledger.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId,
+ InventoryMovementType.Issue, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken);
+ return movement is null ? null : InventoryMovementResponse.From(movement);
+ }
+}
+
+/// Handles current balance listing.
+public sealed class ListInventoryBalancesHandler(IInventoryLedger ledger)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(ListInventoryBalancesQuery request, CancellationToken cancellationToken) =>
+ (await ledger.ListBalancesAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, cancellationToken))
+ .Select(InventoryBalanceResponse.From).ToArray();
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs
new file mode 100644
index 0000000..7eb4d8b
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs
@@ -0,0 +1,19 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Represents a recorded inventory movement exposed to clients.
+public sealed record InventoryMovementResponse(Guid Id, Guid WarehouseId, Guid ProductId, InventoryMovementType Type,
+ decimal Quantity, decimal BalanceAfterQuantity, DateTimeOffset OccurredAtUtc)
+{
+ /// Maps a ledger entry to a response.
+ public static InventoryMovementResponse From(InventoryMovement movement) => new(movement.Id, movement.WarehouseId,
+ movement.ProductId, movement.Type, movement.Quantity, movement.BalanceAfterQuantity, movement.OccurredAtUtc);
+}
+
+/// Represents current on-hand inventory exposed to clients.
+public sealed record InventoryBalanceResponse(Guid WarehouseId, Guid ProductId, decimal Quantity)
+{
+ /// Maps a balance to a response.
+ public static InventoryBalanceResponse From(InventoryBalance balance) => new(balance.WarehouseId, balance.ProductId, balance.Quantity);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs
new file mode 100644
index 0000000..e339183
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+using MediatR;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Describes a request that records an immutable inventory movement.
+public interface IInventoryMovementCommand
+{
+ /// Gets the workspace owning the movement.
+ Guid WorkspaceId { get; }
+ /// Gets the warehouse receiving or issuing stock.
+ Guid WarehouseId { get; }
+ /// Gets the product receiving or issuing stock.
+ Guid ProductId { get; }
+ /// Gets the positive movement quantity.
+ decimal Quantity { get; }
+ /// Gets the retry-stable client idempotency key.
+ string IdempotencyKey { get; }
+}
+
+/// Supplies browser input for recording a movement.
+public sealed record RecordInventoryMovementRequest(Guid WarehouseId, Guid ProductId,
+ [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey);
+
+/// Records a stock receipt in a server-resolved workspace.
+public sealed record RecordReceiptCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity,
+ string IdempotencyKey) : IRequest, IInventoryMovementCommand;
+
+/// Records a stock issue in a server-resolved workspace.
+public sealed record RecordIssueCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity,
+ string IdempotencyKey) : IRequest, IInventoryMovementCommand;
+
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs
new file mode 100644
index 0000000..d6133a9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs
@@ -0,0 +1,7 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Lists current workspace inventory balances.
+public sealed record ListInventoryBalancesQuery(Guid WorkspaceId, Guid? WarehouseId, Guid? ProductId)
+ : IRequest>;
diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs
new file mode 100644
index 0000000..61c296d
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs
@@ -0,0 +1,33 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Inventory;
+
+/// Validates inventory recording input.
+public sealed class RecordReceiptCommandValidator : AbstractValidator
+{
+ /// Initializes validation rules.
+ public RecordReceiptCommandValidator() => InventoryValidation.Configure(this);
+}
+
+/// Validates inventory issue input.
+public sealed class RecordIssueCommandValidator : AbstractValidator
+{
+ /// Initializes validation rules.
+ public RecordIssueCommandValidator() => InventoryValidation.Configure(this);
+}
+
+internal static class InventoryValidation
+{
+ internal static void Configure(AbstractValidator validator) where T : class, IInventoryMovementCommand
+ {
+ validator.RuleFor(x => x.WorkspaceId).NotEmpty();
+ validator.RuleFor(x => x.WarehouseId).NotEmpty();
+ validator.RuleFor(x => x.ProductId).NotEmpty();
+ validator.RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity)
+ .WithMessage("Quantity must have at most four decimal places.")
+ .LessThanOrEqualTo(InventoryMovement.MaxQuantity)
+ .WithMessage("Quantity must not exceed 99999999999999.9999.");
+ validator.RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs b/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs
new file mode 100644
index 0000000..ddcba60
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs
@@ -0,0 +1,25 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Products;
+
+/// Provides workspace-scoped persistence operations for products.
+public interface IProductCatalog
+{
+ /// Creates a product.
+ Task CreateAsync(Product product, CancellationToken cancellationToken);
+ /// Lists active products.
+ Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken);
+ /// Finds a product only within its workspace.
+ Task FindAsync(Guid workspaceId, Guid productId, CancellationToken cancellationToken);
+ /// Archives a product only when no nonzero workspace balance references it.
+ Task ArchiveAsync(Guid workspaceId, Guid productId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken);
+ /// Persists pending changes.
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
+
+/// Represents a duplicate canonical SKU within a workspace.
+public sealed class ProductSkuConflictException : Exception
+{
+ /// Initializes the exception.
+ public ProductSkuConflictException() : base("A product with this SKU already exists in the workspace.") { }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs
new file mode 100644
index 0000000..1747f93
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs
@@ -0,0 +1,29 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Products;
+
+/// Handles product creation.
+public sealed class CreateProductHandler(IProductCatalog catalog, TimeProvider timeProvider) : IRequestHandler
+{
+ ///
+ public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken)
+ {
+ var product = new Product(Guid.NewGuid(), request.WorkspaceId, request.Name, request.Sku, timeProvider.GetUtcNow());
+ return ProductResponse.From(await catalog.CreateAsync(product, cancellationToken));
+ }
+}
+/// Handles active-product listing.
+public sealed class ListProductsHandler(IProductCatalog catalog) : IRequestHandler>
+{
+ ///
+ public async Task> Handle(ListProductsQuery request, CancellationToken cancellationToken) =>
+ (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(ProductResponse.From).ToArray();
+}
+/// Handles idempotent product archive.
+public sealed class ArchiveProductHandler(IProductCatalog catalog, TimeProvider timeProvider) : IRequestHandler
+{
+ ///
+ public Task Handle(ArchiveProductCommand request, CancellationToken cancellationToken) =>
+ catalog.ArchiveAsync(request.WorkspaceId, request.ProductId, timeProvider.GetUtcNow(), cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs
new file mode 100644
index 0000000..8b32c0d
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs
@@ -0,0 +1,19 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Products;
+
+/// Supplies browser input for product creation.
+public sealed record CreateProductRequest(string Name, string Sku);
+/// Represents a product exposed to clients.
+public sealed record ProductResponse(Guid Id, string Name, string Sku, DateTimeOffset CreatedAtUtc)
+{
+ /// Maps a domain product to a response.
+ public static ProductResponse From(Product product) => new(product.Id, product.Name, product.Sku, product.CreatedAtUtc);
+}
+/// Creates a product in a server-resolved workspace.
+public sealed record CreateProductCommand(Guid WorkspaceId, string Name, string Sku) : IRequest;
+/// Lists active products in a server-resolved workspace.
+public sealed record ListProductsQuery(Guid WorkspaceId) : IRequest>;
+/// Archives a product in a server-resolved workspace.
+public sealed record ArchiveProductCommand(Guid WorkspaceId, Guid ProductId) : IRequest;
diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs
new file mode 100644
index 0000000..133bfb8
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs
@@ -0,0 +1,16 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Products;
+
+/// Validates product creation input.
+public sealed class CreateProductCommandValidator : AbstractValidator
+{
+ /// Initializes validation rules.
+ public CreateProductCommandValidator()
+ {
+ RuleFor(command => command.WorkspaceId).NotEmpty();
+ RuleFor(command => command.Name).NotEmpty().MaximumLength(Product.NameMaxLength);
+ RuleFor(command => command.Sku).NotEmpty().MaximumLength(Product.SkuMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs
new file mode 100644
index 0000000..e7069d9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs
@@ -0,0 +1,21 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Purchases;
+
+public sealed class RecordPurchaseReceiptHandler(IPurchaseReceiptService service, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ public async Task Handle(RecordPurchaseReceiptCommand request, CancellationToken cancellationToken)
+ {
+ var receipt = await service.RecordAsync(request.WorkspaceId, request.SupplierId, request.WarehouseId, request.ProductId,
+ request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken);
+ return receipt is null ? null : PurchaseReceiptResponse.From(receipt);
+ }
+}
+
+public sealed class ListPurchaseReceiptsHandler(IPurchaseReceiptService service)
+ : IRequestHandler>
+{
+ public async Task> Handle(ListPurchaseReceiptsQuery request, CancellationToken cancellationToken) =>
+ (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(PurchaseReceiptResponse.From).ToArray();
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs
new file mode 100644
index 0000000..f06e040
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Purchases;
+
+public sealed record RecordPurchaseReceiptRequest(Guid SupplierId, Guid WarehouseId, Guid ProductId,
+ [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey);
+
+public sealed record RecordPurchaseReceiptCommand(Guid WorkspaceId, Guid SupplierId, Guid WarehouseId, Guid ProductId,
+ decimal Quantity, string IdempotencyKey) : MediatR.IRequest;
+
+public sealed record ListPurchaseReceiptsQuery(Guid WorkspaceId) : MediatR.IRequest>;
+
+public sealed record PurchaseReceiptResponse(Guid Id, Guid SupplierId, Guid WarehouseId, Guid ProductId, decimal Quantity,
+ Guid InventoryMovementId, DateTimeOffset ReceivedAtUtc)
+{
+ public static PurchaseReceiptResponse From(PurchaseReceipt receipt) => new(receipt.Id, receipt.SupplierId, receipt.WarehouseId,
+ receipt.ProductId, receipt.Quantity, receipt.InventoryMovementId, receipt.ReceivedAtUtc);
+}
+
+public interface IPurchaseReceiptService
+{
+ Task RecordAsync(Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity,
+ string idempotencyKey, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken);
+ Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs
new file mode 100644
index 0000000..f0db5ab
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Purchases;
+
+public sealed class RecordPurchaseReceiptCommandValidator : AbstractValidator
+{
+ public RecordPurchaseReceiptCommandValidator()
+ {
+ RuleFor(x => x.WorkspaceId).NotEmpty();
+ RuleFor(x => x.SupplierId).NotEmpty();
+ RuleFor(x => x.WarehouseId).NotEmpty();
+ RuleFor(x => x.ProductId).NotEmpty();
+ RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity)
+ .WithMessage("Quantity must have at most four decimal places.")
+ .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999.");
+ RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs
new file mode 100644
index 0000000..cc28023
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs
@@ -0,0 +1,22 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Sales;
+
+public sealed class RecordSalesFulfillmentHandler(ISalesFulfillmentService service, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ public async Task Handle(RecordSalesFulfillmentCommand request, CancellationToken cancellationToken)
+ {
+ var fulfillment = await service.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, request.Quantity,
+ request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken);
+ return fulfillment is null ? null : SalesFulfillmentResponse.From(fulfillment);
+ }
+}
+
+public sealed class ListSalesFulfillmentsHandler(ISalesFulfillmentService service)
+ : IRequestHandler>
+{
+ public async Task> Handle(ListSalesFulfillmentsQuery request,
+ CancellationToken cancellationToken) =>
+ (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(SalesFulfillmentResponse.From).ToArray();
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs
new file mode 100644
index 0000000..b41d4b7
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Sales;
+
+public sealed record RecordSalesFulfillmentRequest(Guid WarehouseId, Guid ProductId,
+ [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey);
+
+public sealed record RecordSalesFulfillmentCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity,
+ string IdempotencyKey) : MediatR.IRequest;
+
+public sealed record ListSalesFulfillmentsQuery(Guid WorkspaceId) : MediatR.IRequest>;
+
+public sealed record SalesFulfillmentResponse(Guid Id, Guid WarehouseId, Guid ProductId, decimal Quantity,
+ Guid InventoryMovementId, DateTimeOffset FulfilledAtUtc)
+{
+ public static SalesFulfillmentResponse From(SalesFulfillment fulfillment) => new(fulfillment.Id, fulfillment.WarehouseId,
+ fulfillment.ProductId, fulfillment.Quantity, fulfillment.InventoryMovementId, fulfillment.FulfilledAtUtc);
+}
+
+public interface ISalesFulfillmentService
+{
+ Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity, string idempotencyKey,
+ DateTimeOffset fulfilledAtUtc, CancellationToken cancellationToken);
+ Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs
new file mode 100644
index 0000000..8ec8987
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs
@@ -0,0 +1,18 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Sales;
+
+public sealed class RecordSalesFulfillmentCommandValidator : AbstractValidator
+{
+ public RecordSalesFulfillmentCommandValidator()
+ {
+ RuleFor(x => x.WorkspaceId).NotEmpty();
+ RuleFor(x => x.WarehouseId).NotEmpty();
+ RuleFor(x => x.ProductId).NotEmpty();
+ RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity)
+ .WithMessage("Quantity must have at most four decimal places.")
+ .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999.");
+ RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs
new file mode 100644
index 0000000..ad1f0f7
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs
@@ -0,0 +1,25 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Suppliers;
+
+/// Provides workspace-scoped persistence operations for suppliers.
+public interface ISupplierCatalog
+{
+ /// Creates a supplier.
+ Task CreateAsync(Supplier supplier, CancellationToken cancellationToken);
+ /// Lists active suppliers.
+ Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken);
+ /// Finds a supplier only within its workspace.
+ Task FindAsync(Guid workspaceId, Guid supplierId, CancellationToken cancellationToken);
+ /// Idempotently archives a supplier only within its workspace.
+ Task ArchiveAsync(Guid workspaceId, Guid supplierId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken);
+ /// Persists pending changes.
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
+
+/// Represents a duplicate normalized supplier name within a workspace.
+public sealed class SupplierNameConflictException : Exception
+{
+ /// Initializes the exception.
+ public SupplierNameConflictException() : base("A supplier with this name already exists in the workspace.") { }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs
new file mode 100644
index 0000000..eae4bf9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs
@@ -0,0 +1,32 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Suppliers;
+
+/// Handles supplier creation.
+public sealed class CreateSupplierHandler(ISupplierCatalog catalog, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(CreateSupplierCommand request, CancellationToken cancellationToken) =>
+ SupplierResponse.From(await catalog.CreateAsync(new Supplier(Guid.NewGuid(), request.WorkspaceId, request.Name,
+ timeProvider.GetUtcNow()), cancellationToken));
+}
+
+/// Handles active supplier listing.
+public sealed class ListSuppliersHandler(ISupplierCatalog catalog)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(ListSuppliersQuery request, CancellationToken cancellationToken) =>
+ (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(SupplierResponse.From).ToArray();
+}
+
+/// Handles idempotent supplier archival.
+public sealed class ArchiveSupplierHandler(ISupplierCatalog catalog, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public Task Handle(ArchiveSupplierCommand request, CancellationToken cancellationToken) =>
+ catalog.ArchiveAsync(request.WorkspaceId, request.SupplierId, timeProvider.GetUtcNow(), cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs
new file mode 100644
index 0000000..e566b2b
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs
@@ -0,0 +1,19 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Suppliers;
+
+/// Supplies browser input for supplier creation.
+public sealed record CreateSupplierRequest(string Name);
+/// Represents a supplier exposed to clients.
+public sealed record SupplierResponse(Guid Id, string Name, DateTimeOffset CreatedAtUtc)
+{
+ /// Maps a domain supplier to a response.
+ public static SupplierResponse From(Supplier supplier) => new(supplier.Id, supplier.Name, supplier.CreatedAtUtc);
+}
+/// Creates a supplier in a server-resolved workspace.
+public sealed record CreateSupplierCommand(Guid WorkspaceId, string Name) : IRequest;
+/// Lists active suppliers in a server-resolved workspace.
+public sealed record ListSuppliersQuery(Guid WorkspaceId) : IRequest>;
+/// Archives a supplier in a server-resolved workspace.
+public sealed record ArchiveSupplierCommand(Guid WorkspaceId, Guid SupplierId) : IRequest;
diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs
new file mode 100644
index 0000000..a55e807
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Suppliers;
+
+/// Validates supplier creation input.
+public sealed class CreateSupplierCommandValidator : AbstractValidator
+{
+ /// Initializes validation rules.
+ public CreateSupplierCommandValidator()
+ {
+ RuleFor(command => command.WorkspaceId).NotEmpty();
+ RuleFor(command => command.Name).NotEmpty().MaximumLength(Supplier.NameMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs
new file mode 100644
index 0000000..3bd8f56
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs
@@ -0,0 +1,22 @@
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Transfers;
+
+public sealed class RecordWarehouseTransferHandler(IWarehouseTransferService service, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ public async Task Handle(RecordWarehouseTransferCommand request, CancellationToken cancellationToken)
+ {
+ var transfer = await service.RecordAsync(request.WorkspaceId, request.SourceWarehouseId, request.DestinationWarehouseId,
+ request.ProductId, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken);
+ return transfer is null ? null : WarehouseTransferResponse.From(transfer);
+ }
+}
+
+public sealed class ListWarehouseTransfersHandler(IWarehouseTransferService service)
+ : IRequestHandler>
+{
+ public async Task> Handle(ListWarehouseTransfersQuery request,
+ CancellationToken cancellationToken) =>
+ (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(WarehouseTransferResponse.From).ToArray();
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs
new file mode 100644
index 0000000..7b51cae
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Transfers;
+
+public sealed record RecordWarehouseTransferRequest(Guid SourceWarehouseId, Guid DestinationWarehouseId, Guid ProductId,
+ [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey);
+
+public sealed record RecordWarehouseTransferCommand(Guid WorkspaceId, Guid SourceWarehouseId, Guid DestinationWarehouseId,
+ Guid ProductId, decimal Quantity, string IdempotencyKey) : MediatR.IRequest;
+
+public sealed record ListWarehouseTransfersQuery(Guid WorkspaceId) : MediatR.IRequest>;
+
+public sealed record WarehouseTransferResponse(Guid Id, Guid SourceWarehouseId, Guid DestinationWarehouseId, Guid ProductId,
+ decimal Quantity, Guid SourceInventoryMovementId, Guid DestinationInventoryMovementId, DateTimeOffset TransferredAtUtc)
+{
+ public static WarehouseTransferResponse From(WarehouseTransfer transfer) => new(transfer.Id, transfer.SourceWarehouseId,
+ transfer.DestinationWarehouseId, transfer.ProductId, transfer.Quantity, transfer.SourceInventoryMovementId,
+ transfer.DestinationInventoryMovementId, transfer.TransferredAtUtc);
+}
+
+public interface IWarehouseTransferService
+{
+ Task RecordAsync(Guid workspaceId, Guid sourceWarehouseId, Guid destinationWarehouseId, Guid productId,
+ decimal quantity, string idempotencyKey, DateTimeOffset transferredAtUtc, CancellationToken cancellationToken);
+ Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs
new file mode 100644
index 0000000..02d57f9
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Transfers;
+
+public sealed class RecordWarehouseTransferCommandValidator : AbstractValidator
+{
+ public RecordWarehouseTransferCommandValidator()
+ {
+ RuleFor(x => x.WorkspaceId).NotEmpty();
+ RuleFor(x => x.SourceWarehouseId).NotEmpty();
+ RuleFor(x => x.DestinationWarehouseId).NotEmpty().NotEqual(x => x.SourceWarehouseId)
+ .WithMessage("Source and destination warehouses must be different.");
+ RuleFor(x => x.ProductId).NotEmpty();
+ RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity)
+ .WithMessage("Quantity must have at most four decimal places.")
+ .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999.");
+ RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength);
+ }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs
new file mode 100644
index 0000000..a7c4643
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs
@@ -0,0 +1,25 @@
+using InventoryFlow.Domain.Entities;
+
+namespace InventoryFlow.Application.Features.Warehouses;
+
+/// Provides workspace-scoped persistence operations for warehouses.
+public interface IWarehouseCatalog
+{
+ /// Creates a warehouse.
+ Task CreateAsync(Warehouse warehouse, CancellationToken cancellationToken);
+ /// Lists active warehouses.
+ Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken);
+ /// Finds a warehouse only within its workspace.
+ Task FindAsync(Guid workspaceId, Guid warehouseId, CancellationToken cancellationToken);
+ /// Archives a warehouse only when no nonzero workspace balance references it.
+ Task ArchiveAsync(Guid workspaceId, Guid warehouseId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken);
+ /// Persists pending changes.
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
+
+/// Represents a duplicate canonical warehouse name within a workspace.
+public sealed class WarehouseNameConflictException : Exception
+{
+ /// Initializes the exception.
+ public WarehouseNameConflictException() : base("A warehouse with this name already exists in the workspace.") { }
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs
new file mode 100644
index 0000000..67c3d3f
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs
@@ -0,0 +1,32 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+
+namespace InventoryFlow.Application.Features.Warehouses;
+
+/// Handles warehouse creation.
+public sealed class CreateWarehouseHandler(IWarehouseCatalog catalog, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(CreateWarehouseCommand request, CancellationToken cancellationToken) =>
+ WarehouseResponse.From(await catalog.CreateAsync(new Warehouse(Guid.NewGuid(), request.WorkspaceId, request.Name,
+ timeProvider.GetUtcNow()), cancellationToken));
+}
+
+/// Handles active warehouse listing.
+public sealed class ListWarehousesHandler(IWarehouseCatalog catalog)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(ListWarehousesQuery request, CancellationToken cancellationToken) =>
+ (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(WarehouseResponse.From).ToArray();
+}
+
+/// Handles warehouse archival.
+public sealed class ArchiveWarehouseHandler(IWarehouseCatalog catalog, TimeProvider timeProvider)
+ : IRequestHandler
+{
+ ///
+ public Task Handle(ArchiveWarehouseCommand request, CancellationToken cancellationToken) =>
+ catalog.ArchiveAsync(request.WorkspaceId, request.WarehouseId, timeProvider.GetUtcNow(), cancellationToken);
+}
diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs
new file mode 100644
index 0000000..59e17ee
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs
@@ -0,0 +1,5 @@
+using InventoryFlow.Domain.Entities;
+using MediatR;
+namespace InventoryFlow.Application.Features.Warehouses;
+public sealed record CreateWarehouseRequest(string Name); public sealed record WarehouseResponse(Guid Id, string Name, DateTimeOffset CreatedAtUtc) { public static WarehouseResponse From(Warehouse x) => new(x.Id, x.Name, x.CreatedAtUtc); }
+public sealed record CreateWarehouseCommand(Guid WorkspaceId, string Name) : IRequest; public sealed record ListWarehousesQuery(Guid WorkspaceId) : IRequest>; public sealed record ArchiveWarehouseCommand(Guid WorkspaceId, Guid WarehouseId) : IRequest;
diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs
new file mode 100644
index 0000000..c3e64dc
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs
@@ -0,0 +1,3 @@
+using FluentValidation;
+using InventoryFlow.Domain.Entities;
+namespace InventoryFlow.Application.Features.Warehouses; public sealed class CreateWarehouseCommandValidator : AbstractValidator { public CreateWarehouseCommandValidator() { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.Name).NotEmpty().MaximumLength(Warehouse.NameMaxLength).Must(name => name.Trim().Length <= Warehouse.NameMaxLength).WithMessage("Warehouse name is too long after trimming."); } }
diff --git a/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj b/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj
new file mode 100644
index 0000000..839bc21
--- /dev/null
+++ b/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/InventoryFlow.Domain/Common/Entity.cs b/backend/src/InventoryFlow.Domain/Common/Entity.cs
new file mode 100644
index 0000000..dae89f4
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Common/Entity.cs
@@ -0,0 +1,23 @@
+namespace InventoryFlow.Domain.Common;
+
+///
+/// Represents a domain entity with an identity.
+///
+/// The type used to identify the entity.
+public abstract class Entity
+ where TId : notnull
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The entity identifier.
+ protected Entity(TId id)
+ {
+ Id = id;
+ }
+
+ ///
+ /// Gets the entity identifier.
+ ///
+ public TId Id { get; protected set; }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs b/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs
new file mode 100644
index 0000000..2745dc9
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs
@@ -0,0 +1,48 @@
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents the current on-hand quantity for a product at a warehouse.
+public sealed class InventoryBalance
+{
+ /// Initializes a zero or positive inventory balance.
+ public InventoryBalance(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity)
+ {
+ if (workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty)
+ throw new DomainException("Inventory balance identifiers are required.");
+ if (quantity < 0m || decimal.Round(quantity, 4) != quantity || quantity > InventoryMovement.MaxQuantity)
+ throw new DomainException("Inventory balance must be nonnegative, have at most four decimal places, and not exceed 99999999999999.9999.");
+ WorkspaceId = workspaceId;
+ WarehouseId = warehouseId;
+ ProductId = productId;
+ Quantity = quantity;
+ }
+
+ /// Gets the owning workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+ /// Gets the warehouse identifier.
+ public Guid WarehouseId { get; private set; }
+ /// Gets the product identifier.
+ public Guid ProductId { get; private set; }
+ /// Gets the current on-hand quantity.
+ public decimal Quantity { get; private set; }
+
+ /// Applies a signed quantity change without allowing negative stock.
+ public void Apply(decimal quantityDelta)
+ {
+ if (decimal.Round(quantityDelta, 4) != quantityDelta)
+ throw new DomainException("Inventory quantity must have at most four decimal places.");
+ var next = Quantity + quantityDelta;
+ if (next < 0m) throw new InsufficientInventoryException();
+ if (next > InventoryMovement.MaxQuantity)
+ throw new DomainException("Inventory balance cannot exceed 99999999999999.9999.");
+ Quantity = next;
+ }
+}
+
+/// Indicates that an issue would make inventory negative.
+public sealed class InsufficientInventoryException : Exception
+{
+ /// Initializes the exception.
+ public InsufficientInventoryException() : base("Insufficient inventory is available for this issue.") { }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs b/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs
new file mode 100644
index 0000000..3162cec
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs
@@ -0,0 +1,77 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Identifies the direction of an inventory ledger entry.
+public enum InventoryMovementType
+{
+ Receipt = 1,
+ Issue = 2
+}
+
+/// Represents an immutable, idempotent inventory ledger entry.
+public sealed class InventoryMovement : Entity
+{
+ /// Maximum quantity representable by the inventory decimal(18,4) columns.
+ public const decimal MaxQuantity = 99999999999999.9999m;
+
+ /// Maximum length of a client supplied idempotency key.
+ public const int IdempotencyKeyMaxLength = 100;
+
+ /// Initializes an inventory movement.
+ public InventoryMovement(Guid id, Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type,
+ decimal quantity, string idempotencyKey, decimal balanceAfterQuantity, DateTimeOffset occurredAtUtc) : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty)
+ throw new DomainException("Inventory movement identifiers are required.");
+ if (!Enum.IsDefined(type)) throw new DomainException("Inventory movement type is invalid.");
+ Quantity = ValidateQuantity(quantity);
+ IdempotencyKey = NormalizeIdempotencyKey(idempotencyKey);
+ if (balanceAfterQuantity < 0m || decimal.Round(balanceAfterQuantity, 4) != balanceAfterQuantity ||
+ balanceAfterQuantity > MaxQuantity)
+ throw new DomainException("Inventory balance must be nonnegative, have at most four decimal places, and not exceed 99999999999999.9999.");
+ if (occurredAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Inventory movement time must be in UTC.");
+
+ WorkspaceId = workspaceId;
+ WarehouseId = warehouseId;
+ ProductId = productId;
+ Type = type;
+ BalanceAfterQuantity = balanceAfterQuantity;
+ OccurredAtUtc = occurredAtUtc;
+ }
+
+ /// Gets the owning workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+ /// Gets the warehouse identifier.
+ public Guid WarehouseId { get; private set; }
+ /// Gets the product identifier.
+ public Guid ProductId { get; private set; }
+ /// Gets the direction of the movement.
+ public InventoryMovementType Type { get; private set; }
+ /// Gets the positive movement quantity.
+ public decimal Quantity { get; private set; }
+ /// Gets the client-supplied idempotency key.
+ public string IdempotencyKey { get; private set; }
+ /// Gets the resulting balance immediately after this movement.
+ public decimal BalanceAfterQuantity { get; private set; }
+ /// Gets the UTC instant at which the movement was recorded.
+ public DateTimeOffset OccurredAtUtc { get; private set; }
+
+ /// Validates a positive decimal quantity representable by inventory decimal(18,4) columns.
+ public static decimal ValidateQuantity(decimal quantity)
+ {
+ if (quantity <= 0m || decimal.Round(quantity, 4) != quantity || quantity > MaxQuantity)
+ throw new DomainException("Inventory quantity must be positive, have at most four decimal places, and not exceed 99999999999999.9999.");
+ return quantity;
+ }
+
+ /// Normalizes and validates an idempotency key.
+ public static string NormalizeIdempotencyKey(string idempotencyKey)
+ {
+ var value = idempotencyKey?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(value) || value.Length > IdempotencyKeyMaxLength)
+ throw new DomainException($"Idempotency key must contain between 1 and {IdempotencyKeyMaxLength} characters.");
+ return value;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/Product.cs b/backend/src/InventoryFlow.Domain/Entities/Product.cs
new file mode 100644
index 0000000..bd783a8
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/Product.cs
@@ -0,0 +1,61 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents a workspace-owned catalog product.
+public sealed class Product : Entity
+{
+ /// Maximum permitted product-name length.
+ public const int NameMaxLength = 200;
+ /// Maximum permitted canonical SKU length.
+ public const int SkuMaxLength = 100;
+
+ /// Initializes a product.
+ public Product(Guid id, Guid workspaceId, string name, string sku, DateTimeOffset createdAtUtc) : base(id)
+ {
+ if (id == Guid.Empty) throw new DomainException("Product identifier is required.");
+ if (workspaceId == Guid.Empty) throw new DomainException("Workspace identifier is required.");
+ if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Product creation time must be in UTC.");
+ WorkspaceId = workspaceId;
+ Name = NormalizeName(name);
+ Sku = NormalizeSku(sku);
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ /// Gets the owning workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+ /// Gets the display name.
+ public string Name { get; private set; }
+ /// Gets the canonical stock-keeping unit.
+ public string Sku { get; private set; }
+ /// Gets the UTC creation instant.
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ /// Gets the UTC archive instant, if archived.
+ public DateTimeOffset? ArchivedAtUtc { get; private set; }
+
+ /// Normalizes and validates a name.
+ public static string NormalizeName(string name)
+ {
+ var normalized = name?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength)
+ throw new DomainException($"Product name must contain between 1 and {NameMaxLength} characters.");
+ return normalized;
+ }
+
+ /// Normalizes and validates a SKU.
+ public static string NormalizeSku(string sku)
+ {
+ var normalized = sku?.Trim().ToUpperInvariant() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > SkuMaxLength)
+ throw new DomainException($"Product SKU must contain between 1 and {SkuMaxLength} characters.");
+ return normalized;
+ }
+
+ /// Archives the product once.
+ public void Archive(DateTimeOffset archivedAtUtc)
+ {
+ if (archivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Product archive time must be in UTC.");
+ ArchivedAtUtc ??= archivedAtUtc;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs
new file mode 100644
index 0000000..f9509d2
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs
@@ -0,0 +1,35 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// An immutable, supplier-linked posted goods receipt.
+public sealed class PurchaseReceipt : Entity
+{
+ public PurchaseReceipt(Guid id, Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity,
+ string idempotencyKey, Guid inventoryMovementId, DateTimeOffset receivedAtUtc) : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || supplierId == Guid.Empty || warehouseId == Guid.Empty ||
+ productId == Guid.Empty || inventoryMovementId == Guid.Empty)
+ throw new DomainException("Purchase receipt identifiers are required.");
+ if (receivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Purchase receipt time must be in UTC.");
+ if (idempotencyKey is null) throw new DomainException("Purchase receipt idempotency key is required.");
+ WorkspaceId = workspaceId;
+ SupplierId = supplierId;
+ WarehouseId = warehouseId;
+ ProductId = productId;
+ Quantity = InventoryMovement.ValidateQuantity(quantity);
+ IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey);
+ InventoryMovementId = inventoryMovementId;
+ ReceivedAtUtc = receivedAtUtc;
+ }
+
+ public Guid WorkspaceId { get; private set; }
+ public Guid SupplierId { get; private set; }
+ public Guid WarehouseId { get; private set; }
+ public Guid ProductId { get; private set; }
+ public decimal Quantity { get; private set; }
+ public string IdempotencyKey { get; private set; }
+ public Guid InventoryMovementId { get; private set; }
+ public DateTimeOffset ReceivedAtUtc { get; private set; }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs b/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs
new file mode 100644
index 0000000..25cb9bf
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs
@@ -0,0 +1,117 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+///
+/// Represents a hashed refresh token issued to an authenticated user.
+///
+public sealed class RefreshToken : Entity
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The refresh-token identifier.
+ /// The identifier of the token owner.
+ /// The one-way hash of the issued refresh token.
+ /// The UTC instant after which the token cannot be used.
+ /// Thrown when an identifier or token hash is empty, or the expiration is not UTC.
+ public RefreshToken(
+ Guid id,
+ Guid userId,
+ Guid familyId,
+ Guid workspaceId,
+ string tokenHash,
+ DateTimeOffset expiresAtUtc)
+ : base(id)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new DomainException("Refresh token identifier is required.");
+ }
+
+ if (userId == Guid.Empty)
+ {
+ throw new DomainException("Refresh token user identifier is required.");
+ }
+
+ if (familyId == Guid.Empty)
+ {
+ throw new DomainException("Refresh token family identifier is required.");
+ }
+
+ if (workspaceId == Guid.Empty)
+ {
+ throw new DomainException("Refresh token workspace identifier is required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(tokenHash))
+ {
+ throw new DomainException("Refresh token hash is required.");
+ }
+
+ if (expiresAtUtc.Offset != TimeSpan.Zero)
+ {
+ throw new DomainException("Refresh token expiration must be in UTC.");
+ }
+
+ UserId = userId;
+ FamilyId = familyId;
+ WorkspaceId = workspaceId;
+ TokenHash = tokenHash;
+ ExpiresAtUtc = expiresAtUtc;
+ }
+
+ ///
+ /// Gets the identifier of the user that owns this token.
+ ///
+ public Guid UserId { get; private set; }
+
+ ///
+ /// Gets the identifier shared by all tokens created from this session.
+ ///
+ public Guid FamilyId { get; private set; }
+
+ ///
+ /// Gets the active workspace for this refresh session.
+ ///
+ public Guid WorkspaceId { get; private set; }
+
+ ///
+ /// Gets the one-way hash of the issued token.
+ ///
+ public string TokenHash { get; private set; }
+
+ ///
+ /// Gets the UTC instant at which this token expires.
+ ///
+ public DateTimeOffset ExpiresAtUtc { get; private set; }
+
+ ///
+ /// Gets the UTC instant at which this token was revoked, if applicable.
+ ///
+ public DateTimeOffset? RevokedAtUtc { get; private set; }
+
+ ///
+ /// Gets a value indicating whether this token is valid at the supplied UTC instant.
+ ///
+ /// The current UTC instant.
+ /// when the token is unrevoked and unexpired; otherwise, .
+ public bool IsActive(DateTimeOffset utcNow) =>
+ RevokedAtUtc is null && ExpiresAtUtc > utcNow;
+
+ ///
+ /// Revokes this token at the supplied UTC instant.
+ ///
+ /// The UTC instant at which the token was revoked.
+ /// Thrown when the supplied instant is not UTC.
+ public void Revoke(DateTimeOffset revokedAtUtc)
+ {
+ if (revokedAtUtc.Offset != TimeSpan.Zero)
+ {
+ throw new DomainException("Refresh token revocation time must be in UTC.");
+ }
+
+ RevokedAtUtc ??= revokedAtUtc;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs b/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs
new file mode 100644
index 0000000..f503fba
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs
@@ -0,0 +1,32 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// An immutable posted single-line sales fulfillment.
+public sealed class SalesFulfillment : Entity
+{
+ public SalesFulfillment(Guid id, Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity,
+ string idempotencyKey, Guid inventoryMovementId, DateTimeOffset fulfilledAtUtc) : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty ||
+ inventoryMovementId == Guid.Empty)
+ throw new DomainException("Sales fulfillment identifiers are required.");
+ if (fulfilledAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Sales fulfillment time must be in UTC.");
+ WorkspaceId = workspaceId;
+ WarehouseId = warehouseId;
+ ProductId = productId;
+ Quantity = InventoryMovement.ValidateQuantity(quantity);
+ IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey);
+ InventoryMovementId = inventoryMovementId;
+ FulfilledAtUtc = fulfilledAtUtc;
+ }
+
+ public Guid WorkspaceId { get; private set; }
+ public Guid WarehouseId { get; private set; }
+ public Guid ProductId { get; private set; }
+ public decimal Quantity { get; private set; }
+ public string IdempotencyKey { get; private set; }
+ public Guid InventoryMovementId { get; private set; }
+ public DateTimeOffset FulfilledAtUtc { get; private set; }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/Supplier.cs b/backend/src/InventoryFlow.Domain/Entities/Supplier.cs
new file mode 100644
index 0000000..6a3db8b
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/Supplier.cs
@@ -0,0 +1,47 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents a workspace-owned supplier catalog entry.
+public sealed class Supplier : Entity
+{
+ /// Maximum permitted supplier-name length.
+ public const int NameMaxLength = 200;
+
+ /// Initializes a supplier.
+ public Supplier(Guid id, Guid workspaceId, string name, DateTimeOffset createdAtUtc) : base(id)
+ {
+ if (id == Guid.Empty) throw new DomainException("Supplier identifier is required.");
+ if (workspaceId == Guid.Empty) throw new DomainException("Workspace identifier is required.");
+ if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Supplier creation time must be in UTC.");
+ WorkspaceId = workspaceId;
+ Name = NormalizeName(name);
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ /// Gets the owning workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+ /// Gets the normalized display name.
+ public string Name { get; private set; }
+ /// Gets the UTC creation instant.
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ /// Gets the UTC archive instant, if archived.
+ public DateTimeOffset? ArchivedAtUtc { get; private set; }
+
+ /// Normalizes and validates a name.
+ public static string NormalizeName(string name)
+ {
+ var normalized = name?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength)
+ throw new DomainException($"Supplier name must contain between 1 and {NameMaxLength} characters.");
+ return normalized;
+ }
+
+ /// Archives the supplier once.
+ public void Archive(DateTimeOffset archivedAtUtc)
+ {
+ if (archivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Supplier archive time must be in UTC.");
+ ArchivedAtUtc ??= archivedAtUtc;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs b/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs
new file mode 100644
index 0000000..2b34183
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs
@@ -0,0 +1,4 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+namespace InventoryFlow.Domain.Entities;
+public sealed class Warehouse : Entity { public const int NameMaxLength = 200; public Warehouse(Guid id, Guid workspaceId, string name, DateTimeOffset createdAtUtc) : base(id) { if (id == Guid.Empty || workspaceId == Guid.Empty || createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Warehouse values are invalid."); WorkspaceId = workspaceId; Name = NormalizeName(name); CreatedAtUtc = createdAtUtc; } public Guid WorkspaceId { get; private set; } public string Name { get; private set; } public DateTimeOffset CreatedAtUtc { get; private set; } public DateTimeOffset? ArchivedAtUtc { get; private set; } public static string NormalizeName(string name) { var value = name?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(value) || value.Length > NameMaxLength) throw new DomainException("Warehouse name is invalid."); return value; } public void Archive(DateTimeOffset value) { if (value.Offset != TimeSpan.Zero) throw new DomainException("Warehouse archive time must be UTC."); ArchivedAtUtc ??= value; } }
diff --git a/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs b/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs
new file mode 100644
index 0000000..6b4407c
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs
@@ -0,0 +1,43 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// An immutable posted single-product transfer between two warehouses.
+public sealed class WarehouseTransfer : Entity
+{
+ public WarehouseTransfer(Guid id, Guid workspaceId, Guid sourceWarehouseId, Guid destinationWarehouseId, Guid productId,
+ decimal quantity, string idempotencyKey, Guid sourceInventoryMovementId, Guid destinationInventoryMovementId,
+ DateTimeOffset transferredAtUtc) : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || sourceWarehouseId == Guid.Empty || destinationWarehouseId == Guid.Empty ||
+ productId == Guid.Empty || sourceInventoryMovementId == Guid.Empty || destinationInventoryMovementId == Guid.Empty)
+ throw new DomainException("Warehouse transfer identifiers are required.");
+ if (sourceWarehouseId == destinationWarehouseId)
+ throw new DomainException("Source and destination warehouses must be different.");
+ if (sourceInventoryMovementId == destinationInventoryMovementId)
+ throw new DomainException("Warehouse transfer movements must be different.");
+ if (transferredAtUtc.Offset != TimeSpan.Zero)
+ throw new DomainException("Warehouse transfer time must be in UTC.");
+
+ WorkspaceId = workspaceId;
+ SourceWarehouseId = sourceWarehouseId;
+ DestinationWarehouseId = destinationWarehouseId;
+ ProductId = productId;
+ Quantity = InventoryMovement.ValidateQuantity(quantity);
+ IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey);
+ SourceInventoryMovementId = sourceInventoryMovementId;
+ DestinationInventoryMovementId = destinationInventoryMovementId;
+ TransferredAtUtc = transferredAtUtc;
+ }
+
+ public Guid WorkspaceId { get; private set; }
+ public Guid SourceWarehouseId { get; private set; }
+ public Guid DestinationWarehouseId { get; private set; }
+ public Guid ProductId { get; private set; }
+ public decimal Quantity { get; private set; }
+ public string IdempotencyKey { get; private set; }
+ public Guid SourceInventoryMovementId { get; private set; }
+ public Guid DestinationInventoryMovementId { get; private set; }
+ public DateTimeOffset TransferredAtUtc { get; private set; }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/Workspace.cs b/backend/src/InventoryFlow.Domain/Entities/Workspace.cs
new file mode 100644
index 0000000..0491db6
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/Workspace.cs
@@ -0,0 +1,34 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents a workspace that owns future Inventory Flow data.
+public sealed class Workspace : Entity
+{
+ /// Maximum permitted workspace-name length.
+ public const int NameMaxLength = 120;
+
+ /// Initializes a workspace.
+ public Workspace(Guid id, string name, DateTimeOffset createdAtUtc) : base(id)
+ {
+ if (id == Guid.Empty) throw new DomainException("Workspace identifier is required.");
+ Name = NormalizeName(name);
+ if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Workspace creation time must be in UTC.");
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ /// Gets the workspace display name.
+ public string Name { get; private set; }
+ /// Gets the UTC creation instant.
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ /// Normalizes and validates a workspace display name.
+ public static string NormalizeName(string name)
+ {
+ var normalized = name?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength)
+ throw new DomainException($"Workspace name must contain between 1 and {NameMaxLength} characters.");
+ return normalized;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs
new file mode 100644
index 0000000..32c3535
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs
@@ -0,0 +1,119 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents a single-use invitation to join a workspace.
+public sealed class WorkspaceInvitation : Entity
+{
+ /// Maximum length for stored normalized invite email addresses.
+ public const int NormalizedEmailMaxLength = 256;
+
+ /// Maximum length for SHA-256 token hashes encoded as hexadecimal.
+ public const int TokenHashMaxLength = 64;
+
+ /// Initializes a workspace invitation.
+ public WorkspaceInvitation(
+ Guid id,
+ Guid workspaceId,
+ string normalizedEmail,
+ WorkspaceMemberRole role,
+ string tokenHash,
+ DateTimeOffset expiresAtUtc,
+ Guid createdByUserId,
+ DateTimeOffset createdAtUtc)
+ : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || createdByUserId == Guid.Empty)
+ throw new DomainException("Invitation, workspace, and creator identifiers are required.");
+ if (role != WorkspaceMemberRole.Member)
+ throw new DomainException("Only Member invitations are supported.");
+ NormalizedEmail = NormalizeEmail(normalizedEmail);
+ TokenHash = NormalizeTokenHash(tokenHash);
+ if (createdAtUtc.Offset != TimeSpan.Zero || expiresAtUtc.Offset != TimeSpan.Zero)
+ throw new DomainException("Invitation timestamps must be in UTC.");
+ if (expiresAtUtc <= createdAtUtc)
+ throw new DomainException("Invitation expiration must be after creation time.");
+
+ WorkspaceId = workspaceId;
+ Role = role;
+ ExpiresAtUtc = expiresAtUtc;
+ CreatedByUserId = createdByUserId;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ /// Gets the workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+
+ /// Gets the normalized invited email address.
+ public string NormalizedEmail { get; private set; }
+
+ /// Gets the invited role.
+ public WorkspaceMemberRole Role { get; private set; }
+
+ /// Gets the one-way hash of the invitation token.
+ public string TokenHash { get; private set; }
+
+ /// Gets the UTC instant at which the invitation expires.
+ public DateTimeOffset ExpiresAtUtc { get; private set; }
+
+ /// Gets the user identifier that created the invitation.
+ public Guid CreatedByUserId { get; private set; }
+
+ /// Gets the UTC creation instant.
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ /// Gets the UTC instant at which the invitation was accepted, if any.
+ public DateTimeOffset? AcceptedAtUtc { get; private set; }
+
+ /// Gets the user identifier that accepted the invitation, if any.
+ public Guid? AcceptedByUserId { get; private set; }
+
+ /// Gets the UTC instant at which the invitation was revoked, if any.
+ public DateTimeOffset? RevokedAtUtc { get; private set; }
+
+ /// Returns whether the invitation can be accepted at the supplied time.
+ public bool IsPending(DateTimeOffset utcNow) =>
+ AcceptedAtUtc is null && RevokedAtUtc is null && ExpiresAtUtc > utcNow;
+
+ /// Marks the invitation as revoked.
+ public void Revoke(DateTimeOffset revokedAtUtc)
+ {
+ if (revokedAtUtc.Offset != TimeSpan.Zero)
+ throw new DomainException("Invitation revocation time must be in UTC.");
+ if (AcceptedAtUtc is not null)
+ throw new DomainException("Accepted invitations cannot be revoked.");
+ if (RevokedAtUtc is not null)
+ throw new DomainException("Invitation has already been revoked.");
+ RevokedAtUtc = revokedAtUtc;
+ }
+
+ /// Marks the invitation as accepted by the supplied user.
+ public void Accept(Guid acceptedByUserId, DateTimeOffset acceptedAtUtc)
+ {
+ if (acceptedByUserId == Guid.Empty)
+ throw new DomainException("Invitation accepter identifier is required.");
+ if (acceptedAtUtc.Offset != TimeSpan.Zero)
+ throw new DomainException("Invitation acceptance time must be in UTC.");
+ if (!IsPending(acceptedAtUtc))
+ throw new DomainException("Invitation is not pending.");
+ AcceptedByUserId = acceptedByUserId;
+ AcceptedAtUtc = acceptedAtUtc;
+ }
+
+ private static string NormalizeEmail(string normalizedEmail)
+ {
+ var value = normalizedEmail?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(value) || value.Length > NormalizedEmailMaxLength)
+ throw new DomainException($"Invitation email must contain between 1 and {NormalizedEmailMaxLength} characters.");
+ return value;
+ }
+
+ private static string NormalizeTokenHash(string tokenHash)
+ {
+ var value = tokenHash?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(value) || value.Length > TokenHashMaxLength)
+ throw new DomainException("Invitation token hash is required.");
+ return value;
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs
new file mode 100644
index 0000000..1cc2d37
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs
@@ -0,0 +1,29 @@
+using InventoryFlow.Domain.Common;
+using InventoryFlow.Domain.Exceptions;
+
+namespace InventoryFlow.Domain.Entities;
+
+/// Represents a user's membership in a workspace.
+public sealed class WorkspaceMember : Entity
+{
+ /// Initializes a workspace member.
+ public WorkspaceMember(Guid id, Guid workspaceId, Guid userId, WorkspaceMemberRole role, DateTimeOffset createdAtUtc) : base(id)
+ {
+ if (id == Guid.Empty || workspaceId == Guid.Empty || userId == Guid.Empty)
+ throw new DomainException("Workspace member, workspace, and user identifiers are required.");
+ if (!Enum.IsDefined(role)) throw new DomainException("Workspace membership role is not supported.");
+ if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Workspace membership creation time must be in UTC.");
+ WorkspaceId = workspaceId;
+ UserId = userId;
+ Role = role;
+ CreatedAtUtc = createdAtUtc;
+ }
+ /// Gets the workspace identifier.
+ public Guid WorkspaceId { get; private set; }
+ /// Gets the Identity user identifier.
+ public Guid UserId { get; private set; }
+ /// Gets the membership role.
+ public WorkspaceMemberRole Role { get; private set; }
+ /// Gets the UTC creation instant.
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+}
diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs
new file mode 100644
index 0000000..5a6d125
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs
@@ -0,0 +1,11 @@
+namespace InventoryFlow.Domain.Entities;
+
+/// Defines the supported workspace membership roles.
+public enum WorkspaceMemberRole
+{
+ /// Owns and administers a workspace.
+ Owner = 1,
+
+ /// Can perform operational work in a workspace.
+ Member = 2,
+}
diff --git a/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs b/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs
new file mode 100644
index 0000000..0e0ccd9
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs
@@ -0,0 +1,26 @@
+namespace InventoryFlow.Domain.Exceptions;
+
+///
+/// Represents a violated domain invariant.
+///
+public sealed class DomainException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A description of the violated invariant.
+ public DomainException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A description of the violated invariant.
+ /// The exception that caused this exception.
+ public DomainException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj b/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj
new file mode 100644
index 0000000..125f4c9
--- /dev/null
+++ b/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs
new file mode 100644
index 0000000..54db052
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs
@@ -0,0 +1,205 @@
+using System.Data;
+using InventoryFlow.Application.Features.Authentication;
+using InventoryFlow.Domain.Entities;
+using InventoryFlow.Infrastructure.Identity;
+using InventoryFlow.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace InventoryFlow.Infrastructure.Authentication;
+
+/// Implements identity, workspace provisioning, session issuance, and refresh rotation.
+public sealed class IdentityAuthenticationService(
+ UserManager userManager,
+ SignInManager signInManager,
+ ApplicationDbContext dbContext,
+ JwtAccessTokenIssuer accessTokenIssuer,
+ RefreshTokenGenerator refreshTokenGenerator,
+ IOptions options,
+ TimeProvider timeProvider) : IAuthenticationService
+{
+ private readonly JwtOptions _options = options.Value;
+
+ ///
+ public async Task RegisterAsync(RegisterUserCommand command, CancellationToken cancellationToken)
+ {
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ return await strategy.ExecuteAsync(async () =>
+ {
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
+ var now = timeProvider.GetUtcNow();
+ var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = command.Email.Trim(), Email = command.Email.Trim(), DisplayName = command.DisplayName.Trim() };
+ var result = await userManager.CreateAsync(user, command.Password);
+ if (!result.Succeeded) throw new AuthenticationException();
+ var workspace = new Workspace(Guid.NewGuid(), CreatePersonalWorkspaceName(user.DisplayName), now);
+ var membership = new WorkspaceMember(Guid.NewGuid(), workspace.Id, user.Id, WorkspaceMemberRole.Owner, now);
+ dbContext.AddRange(workspace, membership);
+ var activeWorkspace = ToWorkspace(workspace.Id, workspace.Name, membership.Role);
+ var session = CreateSession(user, activeWorkspace, [activeWorkspace], Guid.NewGuid(), now);
+ dbContext.RefreshTokens.Add(session.Token);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return session.Session;
+ });
+ }
+
+ ///
+ public async Task LoginAsync(LoginUserCommand command, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByEmailAsync(command.Email.Trim());
+ if (user is null) return null;
+ var result = await signInManager.CheckPasswordSignInAsync(user, command.Password, lockoutOnFailure: true);
+ if (!result.Succeeded) return null;
+ return await IssueSessionAsync(user, Guid.NewGuid(), preferredWorkspaceId: null, cancellationToken);
+ }
+
+ ///
+ public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken)
+ {
+ var hash = refreshTokenGenerator.Hash(refreshToken);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ return await strategy.ExecuteAsync(async () =>
+ {
+ var now = timeProvider.GetUtcNow();
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken);
+ if (token is null) { await transaction.CommitAsync(cancellationToken); return null; }
+ if (!token.IsActive(now))
+ {
+ await RevokeFamilyAsync(token.FamilyId, now, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return null;
+ }
+ token.Revoke(now);
+ var user = await userManager.FindByIdAsync(token.UserId.ToString());
+ var session = user is null ? null : await CreateSessionForWorkspaceAsync(user, token.WorkspaceId, token.FamilyId, now, cancellationToken);
+ if (session is null)
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return null;
+ }
+ dbContext.RefreshTokens.Add(session.Value.Token);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return session.Value.Session;
+ });
+ }
+
+ ///
+ public async Task SwitchWorkspaceAsync(Guid userId, Guid workspaceId, string refreshToken, CancellationToken cancellationToken)
+ {
+ var hash = refreshTokenGenerator.Hash(refreshToken);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ return await strategy.ExecuteAsync(async () =>
+ {
+ var now = timeProvider.GetUtcNow();
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken);
+ if (token is null || token.UserId != userId) { await transaction.CommitAsync(cancellationToken); return null; }
+ if (!token.IsActive(now))
+ {
+ await RevokeFamilyAsync(token.FamilyId, now, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return null;
+ }
+
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ var session = user is null ? null : await CreateSessionForWorkspaceAsync(user, workspaceId, token.FamilyId, now, cancellationToken);
+ if (session is null)
+ {
+ await transaction.CommitAsync(cancellationToken);
+ return null;
+ }
+
+ token.Revoke(now);
+ dbContext.RefreshTokens.Add(session.Value.Token);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return session.Value.Session;
+ });
+ }
+
+ ///
+ public async Task LogoutAsync(string? refreshToken, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(refreshToken)) return;
+ var hash = refreshTokenGenerator.Hash(refreshToken);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ await strategy.ExecuteAsync(async () =>
+ {
+ var now = timeProvider.GetUtcNow();
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken);
+ if (token is not null) { await RevokeFamilyAsync(token.FamilyId, now, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); }
+ await transaction.CommitAsync(cancellationToken);
+ });
+ }
+
+ ///
+ public async Task GetUserAsync(Guid userId, Guid workspaceId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null) return null;
+ var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken);
+ var active = workspaces.SingleOrDefault(workspace => workspace.Id == workspaceId);
+ return active is null ? null : ToUser(user, active, workspaces);
+ }
+
+ private async Task IssueSessionAsync(ApplicationUser user, Guid familyId, Guid? preferredWorkspaceId, CancellationToken cancellationToken)
+ {
+ var now = timeProvider.GetUtcNow();
+ var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken);
+ var active = preferredWorkspaceId is null
+ ? workspaces.FirstOrDefault()
+ : workspaces.SingleOrDefault(workspace => workspace.Id == preferredWorkspaceId.Value);
+ if (active is null) return null;
+ var created = CreateSession(user, active, workspaces, familyId, now);
+ dbContext.RefreshTokens.Add(created.Token);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return created.Session;
+ }
+
+ private async Task<(AuthenticationSession Session, RefreshToken Token)?> CreateSessionForWorkspaceAsync(ApplicationUser user, Guid workspaceId, Guid familyId, DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken);
+ var active = workspaces.SingleOrDefault(workspace => workspace.Id == workspaceId);
+ return active is null ? null : CreateSession(user, active, workspaces, familyId, now);
+ }
+
+ private async Task> GetWorkspacesAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ return await (from member in dbContext.WorkspaceMembers.AsNoTracking()
+ join workspace in dbContext.Workspaces.AsNoTracking() on member.WorkspaceId equals workspace.Id
+ where member.UserId == userId
+ orderby member.Role == WorkspaceMemberRole.Owner descending, member.CreatedAtUtc, workspace.Name
+ select new AuthenticatedWorkspace(workspace.Id, workspace.Name, member.Role.ToString())).ToListAsync(cancellationToken);
+ }
+
+ private (AuthenticationSession Session, RefreshToken Token) CreateSession(ApplicationUser user, AuthenticatedWorkspace activeWorkspace, IReadOnlyCollection workspaces, Guid familyId, DateTimeOffset now)
+ {
+ var refreshValue = refreshTokenGenerator.Create();
+ var token = new RefreshToken(Guid.NewGuid(), user.Id, familyId, activeWorkspace.Id, refreshTokenGenerator.Hash(refreshValue), now.AddDays(_options.RefreshTokenLifetimeDays));
+ var authenticatedUser = ToUser(user, activeWorkspace, workspaces);
+ var access = accessTokenIssuer.Issue(authenticatedUser);
+ return (new AuthenticationSession(new AuthenticationResponse(access.Token, access.ExpiresAtUtc, authenticatedUser), refreshValue), token);
+ }
+
+ private async Task RevokeFamilyAsync(Guid familyId, DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ var activeTokens = await dbContext.RefreshTokens.Where(item => item.FamilyId == familyId && item.RevokedAtUtc == null && item.ExpiresAtUtc > now).ToListAsync(cancellationToken);
+ foreach (var token in activeTokens) token.Revoke(now);
+ }
+
+ private static string CreatePersonalWorkspaceName(string displayName)
+ {
+ const string suffix = "'s workspace";
+ return string.Concat(displayName.AsSpan(0, Math.Min(displayName.Length, Workspace.NameMaxLength - suffix.Length)), suffix);
+ }
+
+ private static AuthenticatedWorkspace ToWorkspace(Guid id, string name, WorkspaceMemberRole role) => new(id, name, role.ToString());
+ private static AuthenticatedUser ToUser(ApplicationUser user, AuthenticatedWorkspace activeWorkspace, IReadOnlyCollection workspaces) => new(user.Id, user.Email ?? string.Empty, user.DisplayName, activeWorkspace, workspaces);
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs
new file mode 100644
index 0000000..97aeb67
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs
@@ -0,0 +1,41 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using InventoryFlow.Application.Features.Authentication;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace InventoryFlow.Infrastructure.Authentication;
+
+/// Issues short-lived signed access tokens.
+public sealed class JwtAccessTokenIssuer(IOptions options, TimeProvider timeProvider)
+{
+ /// Claim type that carries the active workspace identifier.
+ public const string WorkspaceIdClaimType = "workspace_id";
+
+ ///
+ /// Claim type that carries the active workspace role. This is a NON-AUTHORITATIVE UI hint only;
+ /// never use it for access control. The effective role is always revalidated from the database
+ /// membership row per request by CurrentWorkspaceResolver.
+ ///
+ public const string WorkspaceRoleClaimType = "workspace_role";
+
+ private readonly JwtOptions _options = options.Value;
+ /// Creates an access token for a user.
+ public (string Token, DateTimeOffset ExpiresAtUtc) Issue(AuthenticatedUser user)
+ {
+ var expiresAtUtc = timeProvider.GetUtcNow().AddMinutes(_options.AccessTokenLifetimeMinutes);
+ var credentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)), SecurityAlgorithms.HmacSha256);
+ var claims = new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
+ new Claim(ClaimTypes.Email, user.Email),
+ new Claim("display_name", user.DisplayName),
+ new Claim(WorkspaceIdClaimType, user.Workspace.Id.ToString()),
+ new Claim(WorkspaceRoleClaimType, user.Workspace.Role)
+ };
+ var token = new JwtSecurityToken(_options.Issuer, _options.Audience, claims, notBefore: timeProvider.GetUtcNow().UtcDateTime, expires: expiresAtUtc.UtcDateTime, signingCredentials: credentials);
+ return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc);
+ }
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs
new file mode 100644
index 0000000..71ee2ef
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace InventoryFlow.Infrastructure.Authentication;
+
+/// Contains JWT and refresh-session settings.
+public sealed class JwtOptions
+{
+ /// Configuration section name.
+ public const string SectionName = "Jwt";
+ /// Gets or sets the trusted token issuer.
+ [Required] public string Issuer { get; set; } = string.Empty;
+ /// Gets or sets the intended token audience.
+ [Required] public string Audience { get; set; } = string.Empty;
+ /// Gets or sets the secret signing key, supplied outside tracked configuration.
+ [Required, MinLength(32)] public string SigningKey { get; set; } = string.Empty;
+ /// Gets or sets access-token lifetime in minutes.
+ [Range(1, 60)] public int AccessTokenLifetimeMinutes { get; set; } = 10;
+ /// Gets or sets refresh-token lifetime in days.
+ [Range(1, 30)] public int RefreshTokenLifetimeDays { get; set; } = 7;
+ /// Gets or sets the browser refresh cookie name.
+ [Required] public string RefreshCookieName { get; set; } = "inventory_flow_refresh";
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs
new file mode 100644
index 0000000..89dbb65
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs
@@ -0,0 +1,12 @@
+using System.Security.Cryptography;
+
+namespace InventoryFlow.Infrastructure.Authentication;
+
+/// Generates opaque refresh values and their one-way hashes.
+public sealed class RefreshTokenGenerator
+{
+ /// Generates a 256-bit URL-safe opaque value.
+ public string Create() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ /// Hashes an opaque value for persistence.
+ public string Hash(string token) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs b/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs
new file mode 100644
index 0000000..56cccee
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs
@@ -0,0 +1,149 @@
+using System.Data;
+using InventoryFlow.Application.Features.Collaboration;
+using InventoryFlow.Domain.Entities;
+using InventoryFlow.Domain.Exceptions;
+using InventoryFlow.Infrastructure.Authentication;
+using InventoryFlow.Infrastructure.Identity;
+using InventoryFlow.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace InventoryFlow.Infrastructure.Collaboration;
+
+/// Implements workspace collaboration operations with EF Core.
+public sealed class EfCollaborationService(
+ ApplicationDbContext dbContext,
+ UserManager userManager,
+ RefreshTokenGenerator tokenGenerator,
+ TimeProvider timeProvider) : ICollaborationService
+{
+ private static readonly TimeSpan InvitationLifetime = TimeSpan.FromDays(7);
+
+ ///
+ public async Task> ListMembersAsync(ListWorkspaceMembersQuery query, CancellationToken cancellationToken)
+ {
+ EnsureOwner(query.CurrentUserRole);
+ return await (from member in dbContext.WorkspaceMembers.AsNoTracking()
+ join user in dbContext.Users.AsNoTracking() on member.UserId equals user.Id
+ where member.WorkspaceId == query.WorkspaceId
+ orderby member.Role == WorkspaceMemberRole.Owner descending, user.DisplayName
+ select new WorkspaceMemberResponse(user.Id, user.Email ?? string.Empty, user.DisplayName, member.Role.ToString(), member.CreatedAtUtc)).ToListAsync(cancellationToken);
+ }
+
+ ///
+ public async Task> ListInvitationsAsync(ListWorkspaceInvitationsQuery query, CancellationToken cancellationToken)
+ {
+ EnsureOwner(query.CurrentUserRole);
+ return await dbContext.WorkspaceInvitations.AsNoTracking()
+ .Where(invitation => invitation.WorkspaceId == query.WorkspaceId)
+ .OrderByDescending(invitation => invitation.CreatedAtUtc)
+ .Select(invitation => ToResponse(invitation))
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public async Task CreateInvitationAsync(CreateWorkspaceInvitationCommand command, CancellationToken cancellationToken)
+ {
+ EnsureOwner(command.CurrentUserRole);
+ var email = command.Email?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(email)) throw new DomainException("Invitation email is required.");
+ var normalizedEmail = userManager.NormalizeEmail(email);
+ var invitedUser = await userManager.FindByEmailAsync(email);
+ if (invitedUser is null) throw new DomainException("Invitation email must belong to an existing registered user.");
+ if (await dbContext.WorkspaceMembers.AsNoTracking().AnyAsync(member => member.WorkspaceId == command.WorkspaceId && member.UserId == invitedUser.Id, cancellationToken))
+ throw new CollaborationConflictException("The invited user is already a workspace member.");
+
+ var token = tokenGenerator.Create();
+ var now = timeProvider.GetUtcNow();
+ var invitation = new WorkspaceInvitation(
+ Guid.NewGuid(),
+ command.WorkspaceId,
+ normalizedEmail,
+ WorkspaceMemberRole.Member,
+ tokenGenerator.Hash(token),
+ now.Add(InvitationLifetime),
+ command.CreatedByUserId,
+ now);
+ dbContext.WorkspaceInvitations.Add(invitation);
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ catch (DbUpdateException exception) when (IsUniqueConstraint(exception))
+ {
+ throw new CollaborationConflictException("A pending invitation already exists for this email address.");
+ }
+ return new CreatedWorkspaceInvitationResponse(ToResponse(invitation), token);
+ }
+
+ ///
+ public async Task RevokeInvitationAsync(RevokeWorkspaceInvitationCommand command, CancellationToken cancellationToken)
+ {
+ EnsureOwner(command.CurrentUserRole);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ await strategy.ExecuteAsync(async () =>
+ {
+ var now = timeProvider.GetUtcNow();
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var invitation = await dbContext.WorkspaceInvitations.SingleOrDefaultAsync(item => item.Id == command.InvitationId && item.WorkspaceId == command.WorkspaceId, cancellationToken);
+ if (invitation is null) throw new DomainException("Invitation was not found.");
+ if (!invitation.IsPending(now)) throw new DomainException("Only pending invitations can be revoked.");
+ invitation.Revoke(now);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ });
+ }
+
+ ///
+ public async Task AcceptInvitationAsync(AcceptWorkspaceInvitationCommand command, CancellationToken cancellationToken)
+ {
+ var hash = tokenGenerator.Hash(command.Token);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ return await strategy.ExecuteAsync(async () =>
+ {
+ var now = timeProvider.GetUtcNow();
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var invitation = await dbContext.WorkspaceInvitations.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken);
+ if (invitation is null || !invitation.IsPending(now)) throw new DomainException("Invitation is invalid or expired.");
+ var user = await userManager.FindByIdAsync(command.UserId.ToString());
+ if (user is null) throw new DomainException("Authenticated user was not found.");
+ var normalizedEmail = userManager.NormalizeEmail(user.Email ?? string.Empty);
+ if (!string.Equals(invitation.NormalizedEmail, normalizedEmail, StringComparison.Ordinal))
+ throw new DomainException("Invitation email does not match the authenticated user.");
+ if (await dbContext.WorkspaceMembers.AnyAsync(member => member.WorkspaceId == invitation.WorkspaceId && member.UserId == command.UserId, cancellationToken))
+ throw new CollaborationConflictException("The authenticated user is already a workspace member.");
+
+ dbContext.WorkspaceMembers.Add(new WorkspaceMember(Guid.NewGuid(), invitation.WorkspaceId, command.UserId, WorkspaceMemberRole.Member, now));
+ invitation.Accept(command.UserId, now);
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ }
+ catch (DbUpdateException exception) when (IsUniqueConstraint(exception))
+ {
+ throw new CollaborationConflictException("The authenticated user is already a workspace member.");
+ }
+ return ToResponse(invitation);
+ });
+ }
+
+ private static void EnsureOwner(WorkspaceMemberRole role)
+ {
+ if (role != WorkspaceMemberRole.Owner) throw new UnauthorizedAccessException("Owner role is required.");
+ }
+
+ private static WorkspaceInvitationResponse ToResponse(WorkspaceInvitation invitation) => new(
+ invitation.Id,
+ invitation.NormalizedEmail,
+ invitation.Role.ToString(),
+ invitation.ExpiresAtUtc,
+ invitation.CreatedAtUtc,
+ invitation.CreatedByUserId,
+ invitation.AcceptedAtUtc,
+ invitation.AcceptedByUserId,
+ invitation.RevokedAtUtc);
+
+ private static bool IsUniqueConstraint(DbUpdateException exception) => exception.InnerException?.Message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) == true
+ || exception.InnerException?.Message.Contains("unique", StringComparison.OrdinalIgnoreCase) == true;
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
new file mode 100644
index 0000000..aca9198
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
@@ -0,0 +1,123 @@
+using System.Text;
+using InventoryFlow.Application.Features.Authentication;
+using InventoryFlow.Application.Features.Products;
+using InventoryFlow.Application.Features.Collaboration;
+using InventoryFlow.Infrastructure.Authentication;
+using InventoryFlow.Infrastructure.Collaboration;
+using InventoryFlow.Infrastructure.Identity;
+using InventoryFlow.Application.Features.Warehouses;
+using InventoryFlow.Infrastructure.Warehouses;
+using InventoryFlow.Application.Features.Inventory;
+using InventoryFlow.Infrastructure.Inventory;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.Extensions.Options;
+using InventoryFlow.Infrastructure.Persistence;
+using InventoryFlow.Infrastructure.Products;
+using InventoryFlow.Infrastructure.Tenancy;
+using InventoryFlow.Application.Common.Tenancy;
+using InventoryFlow.Application.Features.Suppliers;
+using InventoryFlow.Infrastructure.Suppliers;
+using InventoryFlow.Application.Features.Purchases;
+using InventoryFlow.Infrastructure.Purchases;
+using InventoryFlow.Application.Features.Sales;
+using InventoryFlow.Infrastructure.Sales;
+using InventoryFlow.Application.Features.Transfers;
+using InventoryFlow.Infrastructure.Transfers;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace InventoryFlow.Infrastructure.DependencyInjection;
+
+///
+/// Registers infrastructure-layer services.
+///
+public static class InfrastructureServiceCollectionExtensions
+{
+ ///
+ /// Adds SQL Server persistence and ASP.NET Core Identity services.
+ ///
+ /// The dependency-injection service collection.
+ /// The application configuration.
+ /// The supplied instance.
+ /// Thrown when the database connection string is missing.
+ public static IServiceCollection AddInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var connectionString = configuration.GetConnectionString("InventoryFlowDatabase");
+ ArgumentException.ThrowIfNullOrWhiteSpace(
+ connectionString,
+ "ConnectionStrings:InventoryFlowDatabase");
+
+ services.AddOptions()
+ .Bind(configuration.GetSection(JwtOptions.SectionName))
+ .ValidateDataAnnotations()
+ .Validate(options => Encoding.UTF8.GetByteCount(options.SigningKey) >= 32, "Jwt signing key must be at least 32 bytes.")
+ .ValidateOnStart();
+ var jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions();
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwt.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwt.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.Zero,
+ NameClaimType = "display_name"
+ };
+ });
+ services.AddHttpContextAccessor();
+ services.AddSingleton(TimeProvider.System);
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddDbContext(options => options.UseSqlServer(
+ connectionString,
+ sqlServerOptions => sqlServerOptions.EnableRetryOnFailure()));
+ services.AddDataProtection()
+ .SetApplicationName("InventoryFlow")
+ .PersistKeysToDbContext();
+
+ services.AddIdentityCore(options =>
+ {
+ options.User.RequireUniqueEmail = true;
+ options.Password.RequiredLength = 12;
+ options.Password.RequireDigit = true;
+ options.Password.RequireLowercase = true;
+ options.Password.RequireUppercase = true;
+ options.Password.RequireNonAlphanumeric = true;
+ options.Lockout.AllowedForNewUsers = true;
+ options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
+ options.Lockout.MaxFailedAccessAttempts = 5;
+ })
+ .AddRoles>()
+ .AddEntityFrameworkStores()
+ .AddSignInManager()
+ .AddDefaultTokenProviders();
+
+ return services;
+ }
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs b/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs
new file mode 100644
index 0000000..413c916
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace InventoryFlow.Infrastructure.Identity;
+
+///
+/// Represents an authenticated Inventory Flow user.
+///
+public sealed class ApplicationUser : IdentityUser
+{
+ ///
+ /// Gets or sets the user's display name.
+ ///
+ public string DisplayName { get; set; } = string.Empty;
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs
new file mode 100644
index 0000000..8830bc8
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs
@@ -0,0 +1,41 @@
+using System.Data;
+using InventoryFlow.Application.Features.Inventory;
+using InventoryFlow.Domain.Entities;
+using InventoryFlow.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace InventoryFlow.Infrastructure.Inventory;
+
+/// Persists inventory movements and materialized balances atomically.
+public sealed class EfInventoryLedger(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : IInventoryLedger
+{
+ public async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type,
+ decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, CancellationToken cancellationToken)
+ {
+ quantity = InventoryMovement.ValidateQuantity(quantity);
+ idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
+ return await strategy.ExecuteAsync(async (ct) =>
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var attemptCtx = scope.ServiceProvider.GetRequiredService();
+ await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
+ var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, type, quantity,
+ idempotencyKey, occurredAtUtc, Guid.NewGuid(), ct);
+ if (movement is not null && attemptCtx.Entry(movement).State == EntityState.Added)
+ await attemptCtx.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+ return movement;
+ }, cancellationToken);
+ }
+
+ public async Task> ListBalancesAsync(Guid workspaceId, Guid? warehouseId, Guid? productId,
+ CancellationToken cancellationToken)
+ {
+ var query = dbContext.InventoryBalances.AsNoTracking().Where(balance => balance.WorkspaceId == workspaceId);
+ if (warehouseId.HasValue) query = query.Where(balance => balance.WarehouseId == warehouseId.Value);
+ if (productId.HasValue) query = query.Where(balance => balance.ProductId == productId.Value);
+ return await query.OrderBy(balance => balance.WarehouseId).ThenBy(balance => balance.ProductId).ToListAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs
new file mode 100644
index 0000000..116edc2
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs
@@ -0,0 +1,47 @@
+using InventoryFlow.Domain.Entities;
+using InventoryFlow.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace InventoryFlow.Infrastructure.Inventory;
+
+/// Shared transaction-aware inventory balance and movement writer.
+internal sealed class InventoryLedgerWriter(ApplicationDbContext dbContext)
+{
+ internal async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type,
+ decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, Guid movementId, CancellationToken cancellationToken)
+ {
+ quantity = InventoryMovement.ValidateQuantity(quantity);
+ idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey);
+ if (!Enum.IsDefined(type)) throw new ArgumentOutOfRangeException(nameof(type));
+
+ var existing = await dbContext.InventoryMovements.SingleOrDefaultAsync(movement => movement.WorkspaceId == workspaceId &&
+ movement.IdempotencyKey == idempotencyKey, cancellationToken);
+ if (existing is not null)
+ {
+ if (existing.WarehouseId != warehouseId || existing.ProductId != productId ||
+ existing.Type != type || existing.Quantity != quantity)
+ throw new InvalidOperationException("Idempotency key reused with different parameters.");
+ return existing;
+ }
+
+ var warehouseExists = await dbContext.Warehouses.AnyAsync(warehouse => warehouse.Id == warehouseId &&
+ warehouse.WorkspaceId == workspaceId && warehouse.ArchivedAtUtc == null, cancellationToken);
+ var productExists = await dbContext.Products.AnyAsync(product => product.Id == productId &&
+ product.WorkspaceId == workspaceId && product.ArchivedAtUtc == null, cancellationToken);
+ if (!warehouseExists || !productExists) return null;
+
+ var balance = await dbContext.InventoryBalances.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId &&
+ item.WarehouseId == warehouseId && item.ProductId == productId, cancellationToken);
+ if (balance is null)
+ {
+ balance = new InventoryBalance(workspaceId, warehouseId, productId, 0m);
+ dbContext.InventoryBalances.Add(balance);
+ }
+
+ balance.Apply(type == InventoryMovementType.Receipt ? quantity : -quantity);
+ var movement = new InventoryMovement(movementId, workspaceId, warehouseId, productId, type, quantity, idempotencyKey,
+ balance.Quantity, occurredAtUtc);
+ dbContext.InventoryMovements.Add(movement);
+ return movement;
+ }
+}
diff --git a/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj b/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj
new file mode 100644
index 0000000..582f863
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs
new file mode 100644
index 0000000..9f82019
--- /dev/null
+++ b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs
@@ -0,0 +1,66 @@
+using InventoryFlow.Domain.Entities;
+using InventoryFlow.Infrastructure.Identity;
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace InventoryFlow.Infrastructure.Persistence;
+
+///
+/// Provides SQL Server persistence for Identity and Inventory Flow data.
+///
+/// The options used to configure the context.
+public sealed class ApplicationDbContext(
+ DbContextOptions options)
+ : IdentityDbContext, Guid>(options), IDataProtectionKeyContext
+{
+ ///
+ /// Gets the issued refresh tokens.
+ ///
+ public DbSet RefreshTokens => Set