create mvp path
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# Server C# conventions
|
||||
|
||||
Style, types, naming, async, error handling and tests. The successor to `server/CONVENTIONS.md`.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
When in doubt, ask: *would a senior engineer approve this diff without comment?*
|
||||
|
||||
---
|
||||
|
||||
## 1. Use the right type for the job
|
||||
|
||||
| Scenario | Use |
|
||||
| --- | --- |
|
||||
| Request / response / DTO | `record` — immutable, value semantics |
|
||||
| Domain entity | `class` — mutable state, **encapsulated** |
|
||||
| Shared small value | `readonly record struct` |
|
||||
| Handler, service | `sealed class` |
|
||||
|
||||
### Immutability and safety
|
||||
|
||||
- Mark fields `readonly` unless mutation is genuinely needed.
|
||||
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` in signatures unless the caller must mutate.
|
||||
- **Never expose a public setter on an entity.** Use methods or the constructor. A lifecycle `status` gets a
|
||||
private setter and cohesive transition methods — see [persistence.md](persistence.md) §5.
|
||||
- Avoid `static` mutable state.
|
||||
|
||||
### Null handling
|
||||
|
||||
- `<Nullable>enable</Nullable>` in any new project.
|
||||
- Guard clauses at the entry point; don't scatter null checks through a method.
|
||||
- Prefer `OperationResult.NotFoundResult(...)` over returning `null` from a handler.
|
||||
- **Never `null!`** unless you can prove the value cannot be null and the compiler cannot.
|
||||
|
||||
### Use the language
|
||||
|
||||
```csharp
|
||||
// primary constructor (C# 12)
|
||||
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<…> { }
|
||||
|
||||
// switch expression over an if/else chain
|
||||
var label = status switch
|
||||
{
|
||||
OrderStatus.Pending => "Pending",
|
||||
OrderStatus.Shipped => "Shipped",
|
||||
OrderStatus.Cancelled => "Cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status)),
|
||||
};
|
||||
|
||||
// pattern matching
|
||||
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
|
||||
|
||||
// collection expressions (C# 12)
|
||||
List<string> tags = ["new", "sale"];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Naming
|
||||
|
||||
| Kind | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
|
||||
| Method | PascalCase | `GetUserOrdersAsync` |
|
||||
| Parameter, local | camelCase | `orderId`, `userEmail` |
|
||||
| Private field | `_camelCase` | `_unitOfWork` |
|
||||
| Constant | PascalCase | `MaxRetryCount` |
|
||||
| Generic type parameter | `T`, or descriptive `TEntity` | |
|
||||
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
|
||||
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
|
||||
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
|
||||
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
|
||||
|
||||
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`,
|
||||
`intCount`).
|
||||
|
||||
The `Baya.*` prefix is project naming, not the brand — see [shared/naming.md](../shared/naming.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Routing
|
||||
|
||||
All URL segments are `snake_case`. `SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is
|
||||
registered globally via `RouteTokenTransformerConvention` and converts `[controller]` and `[action]` tokens
|
||||
automatically.
|
||||
|
||||
```csharp
|
||||
// ✅ the transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
public sealed class MyFeatureController : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
public Task<IActionResult> GetBySlug(…) { }
|
||||
}
|
||||
|
||||
// ❌ hardcoded segments bypass the transformer and escape snake_case enforcement
|
||||
[Route("api/v{version:apiVersion}/MyFeature")]
|
||||
[HttpGet("GetBySlug")]
|
||||
```
|
||||
|
||||
**If a method name doesn't read cleanly as a URL, rename the method.** Don't hardcode the route string — it
|
||||
also breaks the dynamic-permission key, which is derived from the same route values.
|
||||
|
||||
The controller skeleton and authorization table are in [cqrs.md](cqrs.md) §4.
|
||||
|
||||
---
|
||||
|
||||
## 4. Async / await
|
||||
|
||||
```csharp
|
||||
// ✅ async all the way — no .Result, no .Wait()
|
||||
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
|
||||
{
|
||||
var entity = await _repository.GetAsync(request.Id, ct);
|
||||
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
|
||||
}
|
||||
|
||||
// ❌ blocks the thread, risks deadlock
|
||||
var result = _repository.GetAsync(id).Result;
|
||||
|
||||
// ❌ fire and forget with no error handling
|
||||
_ = DoSomethingAsync();
|
||||
```
|
||||
|
||||
- **Every public async method accepts a `CancellationToken` and passes it downstream** — including into
|
||||
`SaveChangesAsync(ct)` and `sender.Send(command, ct)`.
|
||||
- Use **`ValueTask<T>`** for hot paths (handlers, repositories); `Task<T>` for rarely-called or always-async
|
||||
methods.
|
||||
- **Never `async void`** — it swallows exceptions. Use `async Task` even for an event-like callback.
|
||||
- **Do not add `.ConfigureAwait(false)`** in this ASP.NET Core app. It is unnecessary here and adds noise.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling and logging
|
||||
|
||||
```csharp
|
||||
// ✅ expected failure — return, don't throw
|
||||
if (user is null)
|
||||
return OperationResult<T>.NotFoundResult("User not found.");
|
||||
|
||||
// ❌ swallowing an exception into a generic failure
|
||||
try { … } catch { return OperationResult<T>.FailureResult(…); }
|
||||
```
|
||||
|
||||
The global `ExceptionHandler` middleware catches unhandled exceptions and logs them. **Do not add a try/catch
|
||||
for unknown exceptions in a handler** — let them propagate. Catch only what you can actually handle.
|
||||
|
||||
Logging rules are in [identity.md](identity.md) §9: structured templates, no PII or secrets, correct level.
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation
|
||||
|
||||
- Every command that accepts user input needs a FluentValidation validator. `ValidateCommandBehavior` runs it
|
||||
automatically before the handler, and `RegisterValidatorsAsServices()` registers them.
|
||||
- **Validate at the boundary** — the command or query — not deep in the domain or a repository.
|
||||
- **Never validate a route-supplied id in the body command.** See [cqrs.md](cqrs.md) §3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mapping — Mapster
|
||||
|
||||
- Use the injected `IMapper` for entity↔DTO mapping **in handlers**.
|
||||
- Register type-adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`; add new
|
||||
assemblies containing mapping configs there.
|
||||
- Never write manual mapping code where Mapster can infer it. Only write a custom `TypeAdapterConfig` when
|
||||
shapes genuinely diverge.
|
||||
- **Mapping happens in the handler after the DB query**, never in the repository — the repository projects.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Arrange — Act — Assert, always
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
|
||||
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Result.Should().NotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
- **Test the handler directly**, not the controller — controllers are thin wrappers.
|
||||
- **`NSubstitute`** for mocking: `Substitute.For<IUnitOfWork>()`.
|
||||
- **Persistence tests use the in-memory SQLite context** from `Baya.Tests.Setup` rather than mocking the DB.
|
||||
- Name tests `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
|
||||
- One assertion *concept* per test. Multiple `.Should()` calls are fine if they verify the same outcome.
|
||||
- **Don't test EF internals** (tracking, migrations) — test behaviour through the handler.
|
||||
|
||||
### Integration tests — the HTTP pipeline
|
||||
|
||||
Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the
|
||||
`OperationResult → IActionResult` translation. **Each feature area needs at least one
|
||||
`WebApplicationFactory<Program>` test** in `Baya.Test.Api` (environment `Testing`, in-memory SQLite) covering:
|
||||
|
||||
1. **Happy path** — an authenticated request returns 200 with the right body shape.
|
||||
2. **Unauthenticated** — returns 401.
|
||||
3. **Validation failure** — returns 400 with field-level error detail.
|
||||
|
||||
```csharp
|
||||
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSomething_Authenticated_Returns200()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/my_feature/get_something");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The recurring-job scheduler is **dormant under `Testing`**, so a background tick can't make an integration
|
||||
test flaky.
|
||||
|
||||
---
|
||||
|
||||
## 9. Service registration
|
||||
|
||||
- Every new infrastructure service gets an extension method in that project's `ServiceConfiguration/` folder,
|
||||
called from `Program.cs`. **No inline DI registration in `Program.cs`.**
|
||||
- Lifetimes: **Singleton** for stateless, thread-safe services (`IHttpContextAccessor`, `IFieldEncryptor` —
|
||||
which *must* be a singleton, see [identity.md](identity.md) §3); **Scoped** for per-request services
|
||||
(repositories, `DbContext`, handlers); **Transient** for lightweight stateless ones (validators,
|
||||
transformers).
|
||||
- **All NuGet versions live only in `Directory.Packages.props`.** Never add `Version=` to a
|
||||
`<PackageReference>` in a `.csproj`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Code organisation
|
||||
|
||||
- **One type per file**, file name matching the type name exactly.
|
||||
- Handlers and validators live in the **same feature folder** — not in a root `Handlers/` or `Validators/`.
|
||||
- A file over **~150 lines** usually means mixed concerns. Consider splitting it.
|
||||
- **Partial classes are only for generated code** (source generators, EF scaffolding) — and the one deliberate
|
||||
exception, `DemoLifecycleSeeder`'s `.Money.cs`/`.Social.cs` partials, which split a Development-only seeder
|
||||
by domain.
|
||||
- **`Program.cs` stays an orchestrator** — extension-method calls only, no logic.
|
||||
|
||||
---
|
||||
|
||||
## 11. No unused code, and comment the *why*
|
||||
|
||||
Both are shared rules with real teeth on this side: the gate is **zero new warnings**, and `CS0168` / `CS0219`
|
||||
/ `CS0169` / `IDE0005` all surface dead code. **Delete it — don't `#pragma warning disable` it.**
|
||||
|
||||
The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely
|
||||
unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
|
||||
|
||||
Full rules, with examples of a comment that earns its place: [shared/code-quality.md](../shared/code-quality.md).
|
||||
|
||||
Known pre-existing warnings that must **not** be fixed unless a task says so:
|
||||
[shared/git-and-gates.md](../shared/git-and-gates.md) §5.
|
||||
@@ -0,0 +1,149 @@
|
||||
# How a server feature is shaped
|
||||
|
||||
Adding a command, a query, a validator, and the controller action that reaches them.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The dispatcher is not MediatR
|
||||
|
||||
CQRS runs on **`martinothamar/Mediator`** — a source-generator-based dispatcher. Use `ISender` / `ICommand` /
|
||||
`IQuery` from that package. Any prose anywhere that says "MediatR" is wrong; do not add MediatR types or
|
||||
`IMediator`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The folder shape
|
||||
|
||||
```
|
||||
Baya.Application/Features/<Area>/
|
||||
├── Commands/<VerbNoun>Command/
|
||||
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
|
||||
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
|
||||
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
|
||||
└── Queries/<VerbNoun>Query/
|
||||
├── <VerbNoun>Query.cs
|
||||
├── <VerbNoun>Query.Handler.cs
|
||||
└── <VerbNoun>Query.Result.cs record Result(…) ← the DTO returned
|
||||
```
|
||||
|
||||
`Features/System/Queries/Ping/` is the minimal live example — query, handler, result — surfaced by
|
||||
`Controllers/V1/PingController`.
|
||||
|
||||
One type per file, and the file name matches the type name.
|
||||
|
||||
---
|
||||
|
||||
## 3. The rules
|
||||
|
||||
- **Requests are `record`s** — immutable, value semantics.
|
||||
- **Handlers are `internal sealed`** — they are never used outside the Application layer.
|
||||
- **Exactly one handler per request type.** No conditional dispatch.
|
||||
- **Never throw for an expected failure.** Return an `OperationResult`:
|
||||
|
||||
| Factory | Maps to |
|
||||
| --- | --- |
|
||||
| `OperationResult<T>.SuccessResult(value)` | 200 |
|
||||
| `OperationResult<T>.FailureResult(errors)` | 400 — validation or business-rule failure, with field-level detail |
|
||||
| `OperationResult<T>.NotFoundResult(message)` | 404 |
|
||||
| `OperationResult.ConflictResult(message)` | 409 — idempotency, duplicate, or an illegal state transition |
|
||||
|
||||
Let a genuinely *unexpected* exception propagate to the global `ExceptionHandler` middleware. Don't
|
||||
try/catch unknown exceptions in a handler, and never swallow one into a `FailureResult`.
|
||||
|
||||
- **Contracts the handler depends on are interfaces in `Application/Contracts/`**, implemented in
|
||||
Infrastructure. A handler never references a concrete infrastructure type.
|
||||
|
||||
- **Validators are FluentValidation** `AbstractValidator<TRequest>`, auto-registered from the Application
|
||||
assembly by `AddApplicationServices` and run by the `ValidateCommandBehavior` pipeline behavior before the
|
||||
handler. Validate **at the boundary** — the command or query — not deep in the domain or a repository.
|
||||
|
||||
```csharp
|
||||
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
||||
{
|
||||
public CreateOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
|
||||
RuleForEach(x => x.Items).ChildRules(item =>
|
||||
{
|
||||
item.RuleFor(i => i.ProductId).GreaterThan(0);
|
||||
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**A route-supplied id must NOT be validated in the body command.** Route values (e.g.
|
||||
`patients/update/{id}`) aren't bound into the body, so a `GreaterThan(0)` on them fails every request.
|
||||
|
||||
- **Pipeline order is Logging → Metrics → Validate.** A new behavior slots into that chain in
|
||||
`AddApplicationServices`, not into a handler.
|
||||
|
||||
---
|
||||
|
||||
## 4. The controller
|
||||
|
||||
Every controller follows this skeleton:
|
||||
|
||||
```csharp
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "One-line description shown in Swagger")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
|
||||
public sealed class MyFeatureController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<MyQueryResult>]
|
||||
public async Task<IActionResult> GetSomething(CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(new MyQuery(), ct));
|
||||
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<MyCommandResult>]
|
||||
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(command, ct));
|
||||
}
|
||||
```
|
||||
|
||||
- **`sealed`.** Controllers are not designed for inheritance beyond `BaseController`.
|
||||
- **Inject `ISender` via the primary constructor**, not `IMediator`.
|
||||
- **Never call `Ok()`, `BadRequest()`, or `NotFound()` directly.** Always `base.OperationResult(result)` —
|
||||
that is what maps `OperationResult` (including 401/403/409) onto the envelope every client already parses.
|
||||
- **Keep the method thin: one `Send`, one `OperationResult`.** No business logic in a controller.
|
||||
- **Use `[Display(Description = "…")]`** so NSwag generates meaningful Swagger tags.
|
||||
- **Pass the `CancellationToken`** from the action into `sender.Send(...)`.
|
||||
- **Route segments come from `[controller]`/`[action]` tokens**, which `SnakeCaseParameterTransformer`
|
||||
converts. Never hardcode a route string — that bypasses the transformer. If a method name doesn't read
|
||||
cleanly as a URL, **rename the method**.
|
||||
|
||||
### Authorization — the narrowest that fits
|
||||
|
||||
| Attribute | When |
|
||||
| --- | --- |
|
||||
| *(none)* | Truly public — health check, metrics, a webhook (which is signature-verified instead) |
|
||||
| `[Authorize]` | Any authenticated user |
|
||||
| `[Authorize(ConstantPolicies.DynamicPermission)]` | A role/claim-gated admin action |
|
||||
| `[RequireTokenWithoutAuthorization]` | A token must be present but may be expired — the refresh endpoint |
|
||||
|
||||
Apply at the **controller** level for a uniform policy; override at the action level only for a genuine
|
||||
exception. Least privilege: an admin action gets `DynamicPermission`, not a bare `[Authorize]`.
|
||||
|
||||
Rate-limit the sensitive ones — see [identity.md](identity.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 5. To add a feature
|
||||
|
||||
1. Create the folder under `Features/<Area>/{Commands|Queries}/<VerbNoun>/`.
|
||||
2. Implement the request, the handler, and a validator if it takes input.
|
||||
3. Add any new dependency as an interface in `Application/Contracts/`, and implement it in Infrastructure —
|
||||
mock and real both, if it is an external rail. See [structure.md](structure.md) §3.
|
||||
4. Wire a controller action to `sender.Send(...)`.
|
||||
5. Add handler unit tests (NSubstitute) **and** at least one `WebApplicationFactory` integration test for the
|
||||
area: happy path 200, unauthenticated 401, validation 400. See [conventions.md](conventions.md) §5.
|
||||
6. Publish the endpoint's contract to [`docs/integration/`](../../integration/index.md).
|
||||
|
||||
If the feature adds a table, read [persistence.md](persistence.md) first — the money, snapshot, state-machine
|
||||
and soft-delete rules there are invariants, not suggestions.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Server identity, encryption and disclosure
|
||||
|
||||
Auth, JWE, sessions, field encryption, tenancy, and the two-stage clinical disclosure rule.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phone-OTP is the public login
|
||||
|
||||
There is no username/password path for a normal user. `Controllers/V1/AuthController`
|
||||
(`request_otp` / `verify_otp` / `refresh` / `logout`) plus `MeController` (`/me`, `select_role`) drive the
|
||||
`Features/Identity/` slices.
|
||||
|
||||
OTP delivery goes through the **`ISmsSender`** seam. The mock (`LoggingSmsSender`) logs the code; the real
|
||||
rails are config-selected — see [structure.md](structure.md) §3.
|
||||
|
||||
### The OTP-capture bridge
|
||||
|
||||
`AddDevelopmentOtpCapture()` decorates the registered `ISmsSender` to capture each OTP in memory for
|
||||
`GET /api/v1/dev/last_otp/{phone}`. It is:
|
||||
|
||||
- **never wired outside Development**, and
|
||||
- **only** wired for a capture-safe provider — `mock`/unset, or the Development-only `telegram` relay.
|
||||
|
||||
**A real gateway (`kavenegar`) disables it**, so a production OTP only ever leaves the process over the SMS
|
||||
wire. `TelegramSmsSender` is the one non-mock provider that keeps the bridge enabled, because it is a
|
||||
**broadcast, not a gateway**: it pushes every code to a fixed list of chat ids so a human tester can read them
|
||||
without grepping logs. Its API key is not committed.
|
||||
|
||||
`DevController` returns 404 outside Development.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tokens and sessions
|
||||
|
||||
- **JWE** — a signed *and* AES-128-encrypted JWT — issued by `IJwtService`
|
||||
(`Baya.Infrastructure.Identity/Jwt/JwtService.cs`). `GenerateAccessTokenAsync` mints an access token only
|
||||
(the REST flow); the legacy `GenerateAsync` additionally writes a `UserRefreshTokens` row and still feeds the
|
||||
gRPC path.
|
||||
- **Every login creates a revocable `usr.UserSessions` row** storing **only the refresh token's
|
||||
`IFieldEncryptor.Hash`** — never the token itself.
|
||||
- **Refresh rotates**: the old session is revoked and a new pair issued.
|
||||
- **A replayed or revoked token revokes ALL of the user's sessions** and returns 401. This is reuse detection,
|
||||
and it is the reason the client's silent refresh is single-flight.
|
||||
- **Logout revokes the session AND rotates the security stamp**, so outstanding access tokens fail the JWE
|
||||
`OnTokenValidated` stamp check. Revoking the session alone would leave a valid access token live for up to
|
||||
its full lifetime.
|
||||
|
||||
Settings bind from `appsettings.json` → `IdentitySettings`. `RequireHttpsMetadata` is **on outside
|
||||
Dev/Testing** (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`,
|
||||
and `Issuer`/`Audience` are real (`Balinyaar` / `BalinyaarClient`).
|
||||
|
||||
**`SecretKey` and `Encryptkey` belong in the environment-specific file**, never in the base
|
||||
`appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder. **Never hardcode a secret
|
||||
in C#** — keys, connection strings and tokens come from configuration bound to typed settings, never a literal
|
||||
in a handler or service.
|
||||
|
||||
---
|
||||
|
||||
## 3. Encrypted PII
|
||||
|
||||
`users.PhoneNumber` / `Email` / `NationalId` are encrypted at rest through an EF value converter over
|
||||
**`IFieldEncryptor`**, wired in `ApplicationDbContext.OnModelCreating`.
|
||||
|
||||
Two consequences that are easy to get wrong:
|
||||
|
||||
- **The encryptor must stay a process-wide singleton**, because EF caches the model. A scoped encryptor gives
|
||||
you a model whose converters point at a disposed instance.
|
||||
- **Equality lookups go through the deterministic `PhoneHash` column** (UNIQUE, synced on `SaveChanges` —
|
||||
which also resets `ShahkarVerifiedAt` when the phone actually changes). **Never query `PhoneNumber == x`**:
|
||||
the ciphertext is not deterministic, so the comparison silently matches nothing.
|
||||
|
||||
### What else is encrypted
|
||||
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `customer_profiles` emergency contact | |
|
||||
| `patients.initial_medical_notes` | |
|
||||
| `customer_addresses` — address line, postal code, recipient name/phone | Decrypted **only in the owner's own read** |
|
||||
| `nurse_bank_accounts.iban` | Plus `UNIQUE(iban_hash)` as a deterministic-hash duplicate guard |
|
||||
| `nurse_payouts.iban_snapshot` | `[AuditRedacted]`, frozen from the verified primary account |
|
||||
| `partner_centers.settlement_iban` | `[AuditRedacted]`, **masked to last 4 in every read** |
|
||||
| `payment_gateways.config_json` | |
|
||||
| `booking_care_instructions` — every field | See §6 |
|
||||
| `patient_care_records.body_encrypted` | Ciphertext with **no EF value converter** — the handler encrypts on write and decrypts only *after* the access check passes |
|
||||
| `messaging.TicketMessages.Body` | Ticket bodies are the refund/dispute paper trail — phone numbers, addresses, clinical detail. Column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule |
|
||||
|
||||
Annotate any encrypted or PII property with **`[AuditRedacted]`** so the audit diff records a marker rather
|
||||
than plaintext.
|
||||
|
||||
> `Seams:FieldEncryption:Key` and `:HashKey` are **load-bearing and must never change.** They decrypt all
|
||||
> existing PII and derive the phone-lookup hash. Rotating them without a re-encryption migration makes every
|
||||
> PII read throw and every phone lookup miss.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roles and permissions
|
||||
|
||||
The full vocabulary is in `Domain/Entities/User/RoleNames`.
|
||||
|
||||
- **`SeedDataBase` always seeds the roles**, and seeds a **bootstrap admin only when
|
||||
`Seed:AdminUsername`/`Seed:AdminPassword` are configured** — break-glass only. There is no committed
|
||||
`admin`/`qw123321` any more. Day-to-day admins come from the phone-OTP demo seeds or are provisioned
|
||||
out-of-band.
|
||||
- **`customer` and `nurse` are self-selectable** via `POST me/select_role` — audited (`granted_by`,
|
||||
`granted_at`), idempotent, and **both can be held** by one user (a dual session moves freely between the
|
||||
family and nurse apps).
|
||||
- **Admin sub-roles are internal-only** and `select_role` returns **403** for them. Never build a flow that
|
||||
implies a user can grant themselves an admin role.
|
||||
- **`user_roles.revoked_at` has a global query filter**, so a revoked grant disappears from every role read
|
||||
automatically.
|
||||
- The **dynamic permission system** (`DynamicPermissionHandler`) reads the `[controller]` + `[action]` route
|
||||
values and checks role claims. **Always use the tokens** so the permission keys stay consistent — a
|
||||
hardcoded route string produces a key nothing grants.
|
||||
|
||||
Auth knobs — `auth_otp_resend_seconds`, `auth_otp_max_attempts`, `auth_session_ttl_days` — are
|
||||
`platform_configs` rows read via `IPlatformConfig`, not constants.
|
||||
|
||||
**`nurse_profiles.is_verified` has no public setter.** It is flipped only by the verification pipeline's
|
||||
guarded cross-aggregate transition — see [persistence.md](persistence.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rate limiting
|
||||
|
||||
Auth and OTP endpoints **must** be rate-limited, using ASP.NET Core's built-in limiter (no extra package).
|
||||
|
||||
| Endpoint | Policy |
|
||||
| --- | --- |
|
||||
| `request_otp`, `verify_otp` | `otp`, plus a per-phone resend window via `ICacheService` |
|
||||
| `refresh` | `auth` |
|
||||
| The PSP and BNPL webhooks | the single deliberate `webhook` policy — bursty-tolerant, partitioned **per provider** |
|
||||
| Admin money/trust actions | `sensitive` |
|
||||
| Everything else | the per-resolved-IP global policy |
|
||||
|
||||
Behind a reverse proxy the limiter partitions on the **forwarded** client IP, which is why
|
||||
`UseForwardedHeaders()` runs first and `UseRateLimiter()` runs before `UseAuthentication()`. See
|
||||
[structure.md](structure.md) §4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Two-stage clinical disclosure
|
||||
|
||||
This is the platform's central privacy invariant. A nurse learns progressively more about a patient as the
|
||||
engagement becomes real, and each stage is enforced **at the query layer**.
|
||||
|
||||
| Stage | When | What the nurse can see |
|
||||
| --- | --- | --- |
|
||||
| **1** — a booking request | Before payment | **Only** the unencrypted, limited `customer_notes` — never routed through `IFieldEncryptor`. The full address is **masked** to a coarse city/district: no line, no postal code, no recipient |
|
||||
| **2** — a confirmed booking | After capture | `booking_care_instructions` (every field encrypted), readable **only post-confirmation** and **only** by the **assigned nurse + admin**. `GetCareInstructionsQuery` enforces it |
|
||||
|
||||
Stage-2 fields are **never projected into a list and never logged.**
|
||||
|
||||
`patient_care_records` are **patient-scoped, not booking-scoped**, encrypted, and behind a strict access check:
|
||||
the owning customer, a nurse with a confirmed booking for that patient, or an admin. Anyone else gets **403**.
|
||||
The handler decrypts only *after* the check passes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tenancy
|
||||
|
||||
**Child rows must belong to the caller.** A patient and an address must be in the caller's `customer_id`; a
|
||||
variant must belong to the requested `nurse_id`.
|
||||
|
||||
Two rules:
|
||||
|
||||
- **Resolve the owner from `ICurrentUser`, never from the request body.** A body-supplied `customer_id` is an
|
||||
authorization bypass waiting to happen.
|
||||
- **A mismatch is a clean 404, never a 403 and never a leak.** A 403 confirms the row exists.
|
||||
|
||||
The same applies to a cross-tenant booking on a review submit, and to the partner portal: a centre resolves
|
||||
from the caller, never from a raw id in the URL.
|
||||
|
||||
**`INotificationService` and the notification endpoints are always tenant-scoped to `ICurrentUser`.**
|
||||
`support_alerts` are **admin-only and must never appear on a user-facing route.**
|
||||
|
||||
---
|
||||
|
||||
## 8. `is_internal` is a hard visibility boundary
|
||||
|
||||
Ticket messages can be internal staff notes. **The boundary is enforced at the QUERY layer, never in the UI.**
|
||||
|
||||
`GetTicketThreadQuery` takes an `AsAdmin` flag:
|
||||
|
||||
- `false` (the user view) — the repository projection **strips every `is_internal` message**
|
||||
(`GetMessagesAsync(includeInternal: false)`).
|
||||
- `true` (staff only) — returns them.
|
||||
|
||||
A non-staff caller can never *set* `is_internal` on `PostMessage`, and can never *read* one. The client mirrors
|
||||
this by not modelling `is_internal` in its user-app types at all — see
|
||||
[client/services.md](../client/services.md) §5 — but **that is a second layer, not the boundary.**
|
||||
|
||||
Related messaging invariants:
|
||||
|
||||
- **There is no direct nurse↔customer channel.** All post-booking communication is ticket-mediated and
|
||||
admin-readable. Participation (`TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via
|
||||
`removed_at`) plus staff status *is* the authorization boundary.
|
||||
- `reference_code` is minted once, collision-checked, UNIQUE, and stable.
|
||||
- `booking_id` and `refund_id` links are both nullable — handle a ticket with neither.
|
||||
- A coordination ticket is auto-created (idempotent, one per booking) on confirmation, dispatched from the card
|
||||
confirm and the BNPL settle handlers. A refund ticket is auto-opened by `CreateRefundCommand` when the caller
|
||||
supplies none, so `refunds.ticket_id` is always non-null.
|
||||
- `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call and **exposes no phone
|
||||
number**. There is no telephony seam by design; the call is a `tel:` link.
|
||||
|
||||
---
|
||||
|
||||
## 9. Logging
|
||||
|
||||
- **Structured logging with message templates**, never string interpolation of values:
|
||||
`_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId)`.
|
||||
- **Never log passwords, tokens, secrets, or full PII.** Email is borderline — use `userId` in logs instead.
|
||||
- The mock SMS sender **never logs the OTP code**; clinical text and IBANs are encrypted or masked before they
|
||||
could reach a log.
|
||||
- Levels: `Debug` for trace detail, `Information` for meaningful events, `Warning` for recoverable issues,
|
||||
`Error` for unexpected failures. Deployed environments write Information+ to `Baya_Logs`, with framework
|
||||
categories held at Warning.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Server money path
|
||||
|
||||
IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the
|
||||
`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the
|
||||
constraint is the authority.
|
||||
|
||||
---
|
||||
|
||||
## 1. Money is IRR `BIGINT`, integer-only
|
||||
|
||||
**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on
|
||||
money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever
|
||||
introduced it must be integer-only.
|
||||
|
||||
- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary** —
|
||||
never in domain or shared code.
|
||||
- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range).
|
||||
- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`.
|
||||
|
||||
### The three booking amounts always reconcile
|
||||
|
||||
```
|
||||
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount (all ≥ 0)
|
||||
```
|
||||
|
||||
This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)`
|
||||
with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered.
|
||||
|
||||
And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the
|
||||
remainder on the last session (`BookingAmounts`).
|
||||
|
||||
### A rate change is never retroactive
|
||||
|
||||
Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in
|
||||
`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.**
|
||||
|
||||
> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at
|
||||
> compute time. Do not live-re-read a rate for an already-priced row.
|
||||
|
||||
---
|
||||
|
||||
## 2. The ledger is the source of truth
|
||||
|
||||
`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so
|
||||
the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path.
|
||||
|
||||
Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through
|
||||
**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg.
|
||||
|
||||
| Posting group | Legs |
|
||||
| --- | --- |
|
||||
| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout |
|
||||
| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission |
|
||||
| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal |
|
||||
| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid |
|
||||
| `RefundPayableClearing` | Posted only once the customer cash-back confirms |
|
||||
| `ClawbackWriteOff` | An admin write-off |
|
||||
| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net |
|
||||
| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` |
|
||||
|
||||
**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
|
||||
stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a
|
||||
`nurse_payout_booking_links` row plus the ledger.
|
||||
|
||||
The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves
|
||||
money itself.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Idempotency
|
||||
|
||||
Three patterns, all mandatory on this path.
|
||||
|
||||
**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and
|
||||
**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side**
|
||||
(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches
|
||||
`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`.
|
||||
**A unique-violation on confirm is an idempotent no-op success, not an error.**
|
||||
|
||||
**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is
|
||||
persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it
|
||||
matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash
|
||||
between execute and claim leaves money moved with nothing recording it.
|
||||
|
||||
**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques
|
||||
on `payment_transactions` — `UNIQUE(gateway_reference_code) WHERE NOT NULL` and
|
||||
`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`.
|
||||
|
||||
A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that
|
||||
would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Capture and conversion
|
||||
|
||||
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
|
||||
initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is
|
||||
**nullable**, bound only when the confirm creates or loads the booking.
|
||||
- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window.
|
||||
- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The
|
||||
card confirm and the BNPL settle both call it rather than re-implementing the split.
|
||||
- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed
|
||||
`DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production
|
||||
converts through the webhook confirm.
|
||||
|
||||
---
|
||||
|
||||
## 5. Refunds and clawbacks
|
||||
|
||||
A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole
|
||||
money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot
|
||||
and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
|
||||
**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes
|
||||
the channel behind its seam, and posts the balanced reversal through `LedgerPosting`.
|
||||
|
||||
The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic.
|
||||
|
||||
### The pre-payout / post-payout fork
|
||||
|
||||
`INursePayoutStatus` answers *"was the nurse already paid?"*
|
||||
|
||||
| Answer | What the reversal debits | Plus |
|
||||
| --- | --- | --- |
|
||||
| Not yet paid | `nurse_payable` — a clean reversal | — |
|
||||
| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert |
|
||||
|
||||
The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a
|
||||
receivable, not a reversal.
|
||||
|
||||
The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a
|
||||
`paid` payout.
|
||||
|
||||
### Channel parity
|
||||
|
||||
`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ:
|
||||
|
||||
| | `psp_card` | `bnpl_revert` |
|
||||
| --- | --- | --- |
|
||||
| Initial status | immediate `succeeded` | `processing` |
|
||||
| Clearing | posts now | deferred to reconciliation |
|
||||
| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~7–10) |
|
||||
|
||||
The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by
|
||||
`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back
|
||||
callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts
|
||||
`RefundPayableClearing` in the same commit, idempotently under the refund lock.
|
||||
`MarkRefundSettlementFailedCommand` is the counterpart.
|
||||
|
||||
The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`).
|
||||
|
||||
**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and
|
||||
supports an admin `write_off`.
|
||||
|
||||
`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the
|
||||
caller supplies none.
|
||||
|
||||
---
|
||||
|
||||
## 6. BNPL — provider-financed installments
|
||||
|
||||
**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment
|
||||
tracking on our side: the provider owns the schedule and **100% of the default risk**.
|
||||
|
||||
- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
|
||||
- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed`
|
||||
(`BnplTransitions`), mutated only through the entity's `mark-*` methods.
|
||||
- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash
|
||||
(`settled_amount_irr = order − commission`), and confirms the parent `payment_transaction` — which triggers
|
||||
the booking conversion — exactly like a card capture.
|
||||
- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split
|
||||
(`gross − commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.**
|
||||
- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from
|
||||
the **actual settlement**, never hardcoded.
|
||||
- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows
|
||||
customer ↔ provider ↔ Balinyaar only.
|
||||
- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house
|
||||
provider** and resolves to the net-of-fee model with no external API.
|
||||
- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Weekly payouts
|
||||
|
||||
- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
|
||||
`dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row.
|
||||
`SetDisputeWindow` is the only eligibility trigger:
|
||||
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`.
|
||||
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE —
|
||||
*not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the
|
||||
backstop.
|
||||
- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the
|
||||
`nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE
|
||||
pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery.
|
||||
- **`net = gross − clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and
|
||||
`[AuditRedacted]`, frozen from the verified primary account.
|
||||
- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via
|
||||
`IHolidayCalendar`; a retry **refuses on a bank-closed day**.
|
||||
- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is
|
||||
paid. A nurse without one is **skipped with a recorded reason**, never silently.
|
||||
- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine,
|
||||
the ledger-exists guard, and a batch idempotency key together.
|
||||
- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr`
|
||||
config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback
|
||||
`POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` flips `submitted → paid/failed`.
|
||||
- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag.
|
||||
|
||||
### Money movement stays human-approved
|
||||
|
||||
The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated
|
||||
(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step
|
||||
remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied
|
||||
`SystemInitiated` value** — that flag is scheduler-only.
|
||||
|
||||
---
|
||||
|
||||
## 8. Invoices
|
||||
|
||||
- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config
|
||||
`vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.**
|
||||
- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter,
|
||||
locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.**
|
||||
- **Idempotent per booking** (`UNIQUE(booking_id)`).
|
||||
- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the
|
||||
target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded
|
||||
platform.
|
||||
- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A
|
||||
`MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cancellation
|
||||
|
||||
The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` +
|
||||
`refund_percentage` + the computed refundable amount are **frozen onto the booking**.
|
||||
|
||||
**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the
|
||||
per-session split is what makes a partial refund on a multi-session package correct.
|
||||
|
||||
Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount.
|
||||
The reversal is the refund path's job (§5).
|
||||
@@ -0,0 +1,382 @@
|
||||
# Server persistence
|
||||
|
||||
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
|
||||
database.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. EF Core basics
|
||||
|
||||
```csharp
|
||||
// ✅ project to a DTO in the query
|
||||
var dto = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => o.UserId == userId)
|
||||
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ loads the entity graph then maps in memory — N+1 risk
|
||||
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
|
||||
var dtos = _mapper.Map<List<OrderResult>>(orders);
|
||||
```
|
||||
|
||||
- **Always `AsNoTracking()`** on a read-only query.
|
||||
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
|
||||
return an entity from a handler**.
|
||||
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
|
||||
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
|
||||
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
|
||||
directly only inside Infrastructure.
|
||||
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
|
||||
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
|
||||
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
|
||||
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
|
||||
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
|
||||
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
|
||||
|
||||
**Migrations:**
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||||
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||||
```
|
||||
|
||||
### Migrations are split from boot
|
||||
|
||||
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
|
||||
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
|
||||
needs no permanent DDL rights.
|
||||
|
||||
| Environment | What boot does |
|
||||
| --- | --- |
|
||||
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
|
||||
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
|
||||
|
||||
A reachable SQL Server is required to start.
|
||||
|
||||
### Soft delete
|
||||
|
||||
Every soft-deletable entity **must** declare a global query filter in its configuration:
|
||||
|
||||
```csharp
|
||||
builder.HasQueryFilter(o => !o.IsDeleted);
|
||||
```
|
||||
|
||||
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
|
||||
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
|
||||
|
||||
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
|
||||
disappears from every role read automatically.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit
|
||||
|
||||
| Field | Type | Set by |
|
||||
| --- | --- | --- |
|
||||
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
|
||||
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
|
||||
|
||||
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
|
||||
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
|
||||
`IDateTimeProvider` and the user from `ICurrentUser` — **not** in the `DbContext`, and **not** in a handler.
|
||||
|
||||
Audit fields cannot be backfilled retroactively, so design them in from the start.
|
||||
|
||||
### The append-only audit trail
|
||||
|
||||
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
|
||||
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
|
||||
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
|
||||
|
||||
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
|
||||
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
|
||||
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
|
||||
|
||||
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
|
||||
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
|
||||
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Config is rows, read at compute time
|
||||
|
||||
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
|
||||
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
|
||||
the row's `data_type`). **Never hardcode one.**
|
||||
|
||||
And the corollary, which is the part that actually matters:
|
||||
|
||||
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
|
||||
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
|
||||
|
||||
The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
|
||||
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
|
||||
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
|
||||
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
|
||||
and must never appear on a user-facing route.
|
||||
|
||||
### Self-committing facades come *after* the atomic commit
|
||||
|
||||
`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
|
||||
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
|
||||
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**
|
||||
|
||||
In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
|
||||
an early `continue` leaks a dirty entity that a later iteration's commit will flush.
|
||||
|
||||
---
|
||||
|
||||
## 4. Money
|
||||
|
||||
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
|
||||
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
|
||||
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
|
||||
|
||||
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
|
||||
path on it anywhere.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Forward-only status machines
|
||||
|
||||
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
||||
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.
|
||||
|
||||
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
|
||||
converter needed.
|
||||
- **Edges live in a static `CanTransition(from, to)`** built from a
|
||||
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
|
||||
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
|
||||
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
|
||||
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
|
||||
Side-effect fields are set in the same method.
|
||||
- **The handler pre-checks and returns a clean 409**:
|
||||
`if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
|
||||
expected "already moved / terminal" case.
|
||||
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
|
||||
|
||||
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
|
||||
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
|
||||
|
||||
### When the enum is a C# enum
|
||||
|
||||
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
|
||||
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
|
||||
projected read happens **in memory after materialization** — `.ToCode()` is not LINQ-translatable. DTOs expose
|
||||
the code string.
|
||||
|
||||
### Guarded cross-aggregate flips
|
||||
|
||||
When one write must atomically change a header row's state **and** a derived boolean on a *different*
|
||||
aggregate (`nurse_verifications.status` → `nurse_profiles.is_verified`): load **both** as tracked entities,
|
||||
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
|
||||
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
|
||||
leave an in-between state.
|
||||
|
||||
`NurseProfile.is_verified` has **no public setter** for this reason.
|
||||
|
||||
### Two SQL Server / SQLite portability rules
|
||||
|
||||
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`** —
|
||||
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
|
||||
by `Id` for the same reason.
|
||||
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
|
||||
committed with the row it numbers, so it is portable and gap-free.
|
||||
|
||||
---
|
||||
|
||||
## 6. Uniqueness patterns
|
||||
|
||||
| Need | Pattern |
|
||||
| --- | --- |
|
||||
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
|
||||
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
|
||||
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
|
||||
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
|
||||
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges` — `users.PhoneHash`. See [identity.md](identity.md) |
|
||||
|
||||
A duplicate returns **409** through `OperationResult.ConflictResult` → `BaseController`'s 409 mapping.
|
||||
|
||||
---
|
||||
|
||||
## 7. Snapshots freeze history
|
||||
|
||||
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
|
||||
moment, and never mutated afterwards:
|
||||
|
||||
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
|
||||
- `platform_fee_rate` on the booking
|
||||
- The resolved cancellation policy `code` + `refund_percentage`
|
||||
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
|
||||
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
|
||||
stored as **absolute UTC**, so a later config change cannot move them
|
||||
|
||||
A later edit to the source variant, address, or policy **never** mutates an existing booking.
|
||||
|
||||
---
|
||||
|
||||
## 8. The search projection
|
||||
|
||||
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
|
||||
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
|
||||
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
|
||||
`ISearchIndexMaintainer`.
|
||||
|
||||
Three invariants:
|
||||
|
||||
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
|
||||
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
|
||||
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
|
||||
surface.
|
||||
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
|
||||
district search matches that district's rows **plus** the NULL-district rows.
|
||||
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
|
||||
`RebuildAsync` is idempotent.
|
||||
|
||||
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
|
||||
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
|
||||
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
|
||||
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
|
||||
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
|
||||
|
||||
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
|
||||
Elasticsearch backend is a config-selected drop-in.
|
||||
|
||||
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
|
||||
never used for coverage matching.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference-data caching
|
||||
|
||||
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme** —
|
||||
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
|
||||
the whole namespace at once rather than enumerating keys.
|
||||
|
||||
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
|
||||
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
|
||||
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
|
||||
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
|
||||
duplicate guard. All required groups must be answered; one value per dimension.
|
||||
|
||||
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
|
||||
is `price` + `price_unit` + `session_count` — never `price` alone.
|
||||
|
||||
---
|
||||
|
||||
## 10. Domain invariants that live here
|
||||
|
||||
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
|
||||
|
||||
**Bookings and EVV**
|
||||
|
||||
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
|
||||
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
|
||||
**nullable**, bound only when the confirm creates or loads the booking.
|
||||
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
|
||||
BNPL settle both call it rather than re-implementing the split.
|
||||
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
|
||||
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
|
||||
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
|
||||
notifies **without blocking**. GPS-denied still checks in, flagged null.
|
||||
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
|
||||
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
|
||||
`payout_eligible_at`.
|
||||
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
|
||||
`(actor, lead-time bucket)` and frozen onto the booking.
|
||||
|
||||
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
|
||||
|
||||
**Reviews**
|
||||
|
||||
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
|
||||
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
|
||||
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
|
||||
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
|
||||
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
|
||||
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
|
||||
the fix for inflated-rating-after-hide drift.
|
||||
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
|
||||
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
|
||||
main commit, never silently swallowed.
|
||||
|
||||
**Partner centres**
|
||||
|
||||
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
|
||||
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
|
||||
when it `is_merchant_of_record`, else `platform`.
|
||||
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
|
||||
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
|
||||
`commission_rate` is separate from `platform_fee_rate`.
|
||||
|
||||
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
|
||||
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
|
||||
|
||||
---
|
||||
|
||||
## 11. The recurring-job scheduler
|
||||
|
||||
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
|
||||
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
|
||||
only external dependency.
|
||||
|
||||
| Job | Cadence |
|
||||
| --- | --- |
|
||||
| `booking_request_expiry` | 1 min (const) |
|
||||
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
|
||||
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
|
||||
| `no_show_sweep` | `no_show_scan_cadence_hours` |
|
||||
| `weekly_payout_generation` | `nurse_payout_interval_days` |
|
||||
| `MoadianReconciliationJob` | 6 h |
|
||||
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
|
||||
|
||||
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
|
||||
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
|
||||
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
|
||||
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
|
||||
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
|
||||
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
|
||||
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
|
||||
Hangfire/Quartz.
|
||||
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
|
||||
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
|
||||
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
|
||||
`SystemInitiated` value**.
|
||||
- **Admin manual triggers are overrides**, running the same idempotent commands.
|
||||
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
|
||||
job and command is unit-tested directly.
|
||||
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
|
||||
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
|
||||
— a row a racing action moved is simply not reloaded.
|
||||
|
||||
---
|
||||
|
||||
## 12. Development seeders
|
||||
|
||||
Both are **Development-only** and idempotent.
|
||||
|
||||
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
|
||||
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
|
||||
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
|
||||
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
|
||||
is demonstrable), and one cross-category required option group.
|
||||
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
|
||||
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
|
||||
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
|
||||
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
|
||||
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
|
||||
case.
|
||||
|
||||
Three rules they establish:
|
||||
|
||||
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
|
||||
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
|
||||
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
|
||||
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
|
||||
rows.
|
||||
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
|
||||
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
|
||||
number, a code, or another natural key.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Server structure
|
||||
|
||||
The layers, the projects, startup wiring, and the seam catalogue.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` — 14 `.csproj` projects, 55 V1 controllers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Clean Architecture, and the one hard boundary
|
||||
|
||||
**Dependencies point inward.**
|
||||
|
||||
```
|
||||
Domain ← Application ← Infrastructure
|
||||
← API
|
||||
```
|
||||
|
||||
- **Domain** references nothing.
|
||||
- **Application** references only Domain.
|
||||
- **Infrastructure** and **API** implement and consume Application contracts.
|
||||
- **Never** make Domain or Application reference Infrastructure or the API. This is not a preference; it is
|
||||
the thing that keeps handlers unit-testable and lets a mock become a real vendor without touching a caller.
|
||||
|
||||
## 2. The projects
|
||||
|
||||
```
|
||||
src/
|
||||
├── Core/
|
||||
│ ├── Baya.Domain Entities, value objects, status-code sets, transition tables
|
||||
│ └── Baya.Application Features/ (CQRS slices) · Contracts/ (the seams) · Models/ · pipeline behaviors
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext · ValueConversion/ · Repositories/ · Configuration/<Area>Config/ · Migrations/ · Interceptors/ · Services/ (DB-backed facades, Scheduling/, Search/, Seeding/)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) · AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready) · OpenTelemetry
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ · appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ · ServiceConfiguration/
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
└── Tests/
|
||||
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
|
||||
├── Baya.Test.Infrastructure.Identity xUnit identity tests
|
||||
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
|
||||
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
|
||||
```
|
||||
|
||||
**Domain entity folders**, one per bounded area: `User/`, `Identity/`, `Geography/`, `Catalog/`,
|
||||
`Verification/`, `Search/`, `Booking/`, `Payments/`, `Refunds/`, `Invoices/`, `Bnpl/`, `Payouts/`, `Reviews/`,
|
||||
`Messaging/`, `PartnerCenters/`, plus `Configuration/`, `Audit/`, `Analytics/`, `Holidays/`,
|
||||
`Notifications/`, `SupportAlerts/`. `Common/` holds `BaseEntity`, `IEntity`, `ITimeModification`,
|
||||
`IAuditableEntity`, `IAuditable`, `[AuditRedacted]`.
|
||||
|
||||
**Application feature areas** mirror them: `Identity`, `Geography`, `ServiceAreas`, `Addresses`, `Catalog`,
|
||||
`Variants`, `Verification`, `Search`, `Booking` (singular — pre-payment requests), `Bookings` (plural — the
|
||||
post-payment engine), `Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`,
|
||||
`Messaging`, `PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`,
|
||||
`SupportAlerts`, `System`.
|
||||
|
||||
> `Booking` (singular) and `Bookings` (plural) are **different areas, not a rename.** A booking request is
|
||||
> the money-free pre-payment intent; a booking exists only after capture. The entity type `Booking` is
|
||||
> aliased where the two namespaces collide. The same split is load-bearing in the client's
|
||||
> `bookingRequests`/`bookings` domains and in Persian copy («درخواست رزرو» vs «رزرو»).
|
||||
|
||||
**Database schemas**, one per area, mirroring how Identity uses `usr`: `usr`, `ops`, `geo`, `catalog`,
|
||||
`verif`, `search`, `booking`, `payments`, `payouts`, `reviews`, `messaging`, `partner`.
|
||||
|
||||
**Keeping this current is mandatory.** When a change adds, removes, or renames a project, a layer, or a major
|
||||
folder, or changes a cross-layer dependency, update the **Project map** in
|
||||
[server/CLAUDE.md](../../../server/CLAUDE.md) and this section in the **same** change. A map is only canonical
|
||||
if it stays accurate.
|
||||
|
||||
---
|
||||
|
||||
## 3. The seams
|
||||
|
||||
The Application layer defines every mock-able external dependency as an interface. Implementations live in
|
||||
Infrastructure and are chosen by **registration**, never by a branch in a handler.
|
||||
|
||||
| Contracts folder | Seams |
|
||||
| --- | --- |
|
||||
| `Contracts/Common/` | `IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, `ILicenseVerificationService`, `IBankAccountOwnershipVerifier`, `IVariantSnapshotSerializer`, `IPaymentCaptureSimulator`, `ISmsSender`, `ICurrentUser` |
|
||||
| `Contracts/Payments/` | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`, `IBnplProvider`, `IBnplProviderResolver`, `ICurrencyNormalizer`, `IBankTransferProvider`, `IMoadianClient`, `INursePayoutStatus` |
|
||||
| `Contracts/Search/` | `INurseSearch` (read), `ISearchIndexMaintainer` (write) |
|
||||
| `Contracts/Reviews/` | `IReviewModerationService` (the AI pre-screen) |
|
||||
| `Contracts/Persistence/` | The per-domain repositories, all exposed on `IUnitOfWork` |
|
||||
| Platform facades | `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`, `INotificationService`, `ISupportAlertService` |
|
||||
|
||||
### Where each implementation lives
|
||||
|
||||
| Kind | Location | Registered by |
|
||||
| --- | --- | --- |
|
||||
| Mocks | `CrossCutting/Seams/` | `AddCrossCuttingSeams(configuration)` — config section `Seams` |
|
||||
| Real vendor adapters | `CrossCutting/Seams/Real/` | the same, selected per rail |
|
||||
| Platform facades (DB-backed) | `Persistence/Services/` | `AddPersistenceServices` — **not** CrossCutting, because they are DB-backed |
|
||||
| `ICurrentUser` | `Infrastructure.Identity` | `RegisterIdentityServices` |
|
||||
|
||||
Audit fields are stamped by `AuditFieldInterceptor` (Persistence), never in a handler.
|
||||
|
||||
### Real rails are config-selected, and the default falls closed
|
||||
|
||||
Every vendor rail has a real HTTP adapter, selected by a per-rail **`Seams:*:Provider`** key in
|
||||
`AddCrossCuttingSeams`. **The default is the mock, and a typo falls closed to the mock** — so an unconfigured
|
||||
environment behaves exactly as before, and a misconfigured one does not silently reach a live vendor.
|
||||
|
||||
Real adapters use `HttpClient` (typed via `IHttpClientFactory`), `System.Text.Json`, and BCL crypto —
|
||||
**no new NuGet packages**. Credentials come from `Seams:*`.
|
||||
|
||||
| Rail | Selector | Adapter |
|
||||
| --- | --- | --- |
|
||||
| SMS/OTP | `Sms:Provider=kavenegar` | `KavenegarSmsSender` — **launch-critical** |
|
||||
| SMS/OTP (demo) | `Sms:Provider=telegram` | `TelegramSmsSender` — a **broadcast, not a gateway** |
|
||||
| Shahkar / KYC / IBAN ownership | `{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech` | `Finnotech*`, shared `Seams:Finnotech` creds |
|
||||
| Geocoding | `Geocoding:Provider=neshan` | `NeshanGeocoder` |
|
||||
| Object storage | `ObjectStorage:Provider=s3` | `S3ObjectStorage` — MinIO/S3/ArvanCloud via manual AWS SigV4; presigned GET is the real signed-URL contract |
|
||||
| PSP | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider` |
|
||||
| BNPL | `Bnpl:Provider=real` | `SnappPayBnplProvider` / `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`. **`balinyaar` = in-house, resolving to the net-of-fee model with no external API** |
|
||||
| Bank transfer | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` — an **async rail**: it accepts as `submitted`, and the HMAC-verified reconciliation callback `POST webhooks/payouts/{provider}` flips `submitted → paid/failed` |
|
||||
| e-invoicing | `Moadian:Provider=moadian` | `MoadianClient` + a 6-hour `MoadianReconciliationJob` walking `pending/submitted → registered` |
|
||||
|
||||
Three deliberate exceptions:
|
||||
|
||||
- **`IPaymentCaptureSimulator` is out of the production registration.** Production gets the fail-closed
|
||||
`DisabledPaymentCaptureSimulator`; Dev and Testing re-register the succeeding mock. The `bookings/convert`
|
||||
path is a Dev/Testing affordance — production converts through the b10 webhook confirm.
|
||||
- **`ICredentialVerifier` / `ILicenseVerificationService` stay mock**, because **manual MoH / INO / eNamad
|
||||
review is the intended MVP** — there is no public B2B API. Don't "finish" them.
|
||||
- **There is no telephony/VoIP seam.** The emergency call is an out-of-platform `tel:` link by design.
|
||||
|
||||
`ICurrencyNormalizer` is already config-driven with a real implementation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Startup wiring
|
||||
|
||||
Service registration is composed from per-layer extension methods, each in that project's
|
||||
`ServiceConfiguration/` folder. **`Program.cs` is an orchestrator: extension-method calls only, no logic and
|
||||
no inline registration.**
|
||||
|
||||
```
|
||||
builder.ValidateRequiredSecrets() // fail fast on a missing/placeholder DB or crypto secret
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry()
|
||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||
RegisterIdentityServices(…, requireHttpsMetadata)
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories,
|
||||
// the IRecurringJob crons + RecurringJobSchedulerHostedService
|
||||
AddCrossCuttingSeams(config)
|
||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||
AddCorsPolicies(config) // from Cors:AllowedOrigins
|
||||
AddForwardedHeadersConfiguration(config) // trust ForwardedHeaders:KnownProxies/KnownNetworks
|
||||
AddRateLimitingPolicies() // per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||
ConfigureGrpcPluginServices(builder.Environment) // gRPC reflection: Development only
|
||||
// Development-only: AddDevelopmentOtpCapture() decorates ISmsSender to capture each OTP in memory for
|
||||
// GET /api/v1/dev/last_otp/{phone}. Never wired outside Development, and only for a capture-safe
|
||||
// Seams:Sms:Provider (mock/unset, or the Development-only telegram relay). Kavenegar disables it.
|
||||
```
|
||||
|
||||
**When you add infrastructure, expose it as an extension method and call it from `Program.cs`.**
|
||||
|
||||
### Middleware order, and why each position matters
|
||||
|
||||
```
|
||||
forwarded headers → exception handler → Swagger → routing → CORS → rate limiter
|
||||
→ authentication → authorization → controllers → metrics → health checks → gRPC
|
||||
```
|
||||
|
||||
- **`UseForwardedHeaders()` is first**, so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is
|
||||
in place before the rate limiter partitions on it. Behind a proxy without it, the limiter sees one IP and
|
||||
throttles everyone together.
|
||||
- **`UseCors(...)` sits after `UseRouting()` and before `UseRateLimiter()`**, so a pre-flight `OPTIONS` is
|
||||
answered before the limiter and auth run.
|
||||
- **`UseRateLimiter()` is before `UseAuthentication()`**, so over-limit auth and OTP attempts are rejected
|
||||
with 429 before hitting the auth stack.
|
||||
|
||||
### Fail-fast on secrets
|
||||
|
||||
`StartupSecretsGuard` (via `ValidateRequiredSecrets()`) refuses to start if a load-bearing secret is missing
|
||||
or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder: the DB connection strings always, plus the
|
||||
JWE and field-encryption keys in deployed environments.
|
||||
|
||||
> The placeholder's *name* is stale — `dotnet user-secrets` is **not used** and the `<UserSecretsId>` was
|
||||
> removed, so that store is never read. The behaviour is correct; the string is a legacy name. See
|
||||
> [code-quality.md](../shared/code-quality.md) §6 for where config actually lives.
|
||||
|
||||
---
|
||||
|
||||
## 5. Observability and health
|
||||
|
||||
One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, `SetupOpenTelemetry`):
|
||||
|
||||
- **Metrics** — runtime + ASP.NET Core + the `mediator_meter` histogram, scraped at `/metrics` via the OTel
|
||||
Prometheus exporter. (The duplicate prometheus-net stack was removed.)
|
||||
- **Tracing** — ASP.NET Core + EF Core, sharing `service.name = Baya.Web.Api`.
|
||||
- **OTLP export (traces + metrics) is opt-in** — wired only when `OpenTelemetry:Otlp:Endpoint` is set, so an
|
||||
MVP running Prometheus alone is unchanged.
|
||||
- **`ApiResult.RequestId` IS the W3C trace id** (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`),
|
||||
so a support ticket maps 1:1 to a trace. Don't replace it with a random correlation id.
|
||||
|
||||
**Health checks are split**: `/healthz/live` (process only, dependency-free — for a liveness probe),
|
||||
`/healthz/ready` (app DB + `logDb` in deployed environments + an `IObjectStorage` write probe), and
|
||||
`/HealthCheck` (the aggregate, kept for compatibility).
|
||||
|
||||
**Logs**: deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.
|
||||
**No PII, no secrets** — the mock SMS sender never logs the OTP code, and clinical text and IBANs are
|
||||
encrypted or masked. Set the OTLP collector to ship logs off-box; the SQL sink is the deployed default.
|
||||
|
||||
**gRPC reflection is Development-only** (`GrpcPluginStartup` gates it on `IsDevelopment`); the plugin shares
|
||||
the mixed-protocol Kestrel listener.
|
||||
Reference in New Issue
Block a user