refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)

The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was
a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint
re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing
it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept
included, via a null Tag rather than a sixth enum member) as a closed type,
constructible only through its factories. Aanvraag.StatusAt(now) carries the logic
verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become
one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api
shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET,
which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised
tag.

Also, WP-68 F2: the besluit transition-legality check now runs inside
ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand —
two concurrent besluiten used to both pass the check before either wrote, letting
the second silently overwrite a terminal decision. RecordBesluit returns an
Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult.

Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check
into BeoordelingRules.RequiresToelichting, alongside CanDecide.

The three tests naming this refactor's regression net
(AanvraagStatusTag_covers_the_published_lifecycle,
AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-05 15:34:10 +02:00
co-authored by Claude Opus 5
parent fd04221d2f
commit fc6e73806a
13 changed files with 159 additions and 78 deletions
@@ -1,4 +1,5 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.People;
@@ -36,31 +37,17 @@ public static class Mappers
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling until a behandelaar records a decision (WP-65b — before that
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
// testable by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
// behandelaar's explicit besluit is authoritative once made.
if (a.BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
/// <summary>Wire projection of an <see cref="AanvraagStatus"/> — the null "Concept" case
/// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
/// convention's magic string here at the boundary rather than living inside the domain type.
/// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
/// agree on the projection (WP-68 F3).</summary>
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
// Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line
// projection of that domain method onto the wire DTO (WP-68 F3).
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,