From 1f1c944a8b6f7a34a0d1d680d66f5523a08879d9 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 09:15:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(domain):=20withdrawal=20=E2=80=94=20INGETR?= =?UTF-8?q?OKKEN=20transition=20+=20endpoint=20(S-11a,=20refs=20#12)=20(#8?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why First sub-slice of **S-11 · Withdrawal (Flow 3)** (#12). A zorgprofessional can withdraw a still-open registration ("trek aanvraag in"); this sub-slice delivers the **domain transition + endpoint**, mirroring how S-12a shipped the beoordeling decision model on its own (#82). - `RegistrationStatus.Ingetrokken` (terminal). - `Registration.Withdraw()` — allowed from INGEDIEND or IN_BEHANDELING, needs no zaak, idempotent, and rejected once the registration has been decided (INGESCHREVEN/AFGEWEZEN). - `WithdrawRegistration` application handler (load → withdraw → persist; repeated withdrawal is a no-op). - `POST /registrations/{id}/withdraw` on the domain API. Demoable: `POST /registrations/{id}/withdraw` → `GET /registrations/{id}` shows `INGETROKKEN`. Refs #12 (not closing — see below). ## Scope / follow-ups S-11 is bigger than one slice, so it is split (CLAUDE.md §13), like S-12 was: - **S-11a (this PR)** — domain withdrawal transition + endpoint. - **S-11b** — cancel the running Flowable process via a BPMN message event, so a withdrawn case leaves the behandelaar's werkbak. - **S-11c** — owner-scoped BFF self-service withdraw endpoint + "trek aanvraag in" button + e2e. Cancelling the Flowable process is deliberately deferred (documented in `WithdrawRegistration`), exactly as the beoordeling's rejection deferred its zaak propagation. #12 stays open until S-11c. ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing test committed before the implementation. - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected (no infra/contract change). - [x] Docs — none needed for this backend sub-slice; the user-visible demo note lands with S-11c. - [x] No ADR needed — mirrors existing aggregate/handler/endpoint patterns; no boundary change. ## Notes for reviewers - Verified locally: `Big.Tests` 89/89 pass; `Big.Api` builds clean. - The domain trusts its callers (§8.3); owner-scoping by the caller's bsn is enforced at the BFF in S-11c. Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/88 --- services/domain/Big.Api/Program.cs | 14 ++++ .../Big.Application/WithdrawRegistration.cs | 32 +++++++++ services/domain/Big.Domain/Registration.cs | 18 ++++- .../domain/Big.Domain/RegistrationStatus.cs | 6 +- .../domain/Big.Tests/RegistrationTests.cs | 68 +++++++++++++++++++ .../Big.Tests/WithdrawRegistrationTests.cs | 60 ++++++++++++++++ 6 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 services/domain/Big.Application/WithdrawRegistration.cs create mode 100644 services/domain/Big.Tests/WithdrawRegistrationTests.cs diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 176ef91..6b8dccb 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -74,6 +75,19 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, return Results.NoContent(); }); +// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back, +// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped +// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable +// process is a later sub-slice (S-11b). +app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) => +{ + if (!Guid.TryParse(id, out var guid)) + return Results.NotFound(); + + await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct); + return Results.NoContent(); +}); + // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open // Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind // medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3). diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs new file mode 100644 index 0000000..282d35a --- /dev/null +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -0,0 +1,32 @@ +using Big.Domain; + +namespace Big.Application; + +/// A zorgprofessional's request to withdraw their own registration ("trek aanvraag in"). +public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); + +/// +/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It +/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered +/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again). +/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later +/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only — +/// mirroring how the beoordeling's rejection deferred its zaak propagation. +/// +public sealed class WithdrawRegistration(IRegistrationStore store) +{ + public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + + var registration = await store.GetAsync(command.RegistrationId, ct) + ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw."); + + // A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again. + if (registration.Status == RegistrationStatus.Ingetrokken) + return; + + registration.Withdraw(); + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index 3aa620d..4b29eab 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -111,7 +111,23 @@ public sealed class Registration Status = RegistrationStatus.Afgewezen; } - // A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING). + /// + /// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed + /// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has + /// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one + /// already is a no-op. + /// + public void Withdraw() + { + if (Status == RegistrationStatus.Ingetrokken) + return; + + RequireOpenForDecision(nameof(Withdraw)); + Status = RegistrationStatus.Ingetrokken; + } + + // A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or + // IN_BEHANDELING). private void RequireOpenForDecision(string decision) { if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) diff --git a/services/domain/Big.Domain/RegistrationStatus.cs b/services/domain/Big.Domain/RegistrationStatus.cs index edae5a9..fccfac7 100644 --- a/services/domain/Big.Domain/RegistrationStatus.cs +++ b/services/domain/Big.Domain/RegistrationStatus.cs @@ -3,7 +3,8 @@ namespace Big.Domain; /// The lifecycle states a moves through. Submission starts in /// ; a behandelaar takes it and decides it into one /// of the terminal states (approved) or (rejected). -/// Withdrawal and herregistratie states arrive in their own slices (S-11+). +/// A zorgprofessional can withdraw a still-open registration into (S-11). +/// The herregistratie state arrives in its own slice. public enum RegistrationStatus { /// Submitted by the zorgprofessional; the registratie process has been started. @@ -17,4 +18,7 @@ public enum RegistrationStatus /// Rejected by the behandelaar. Terminal. Afgewezen, + + /// Withdrawn by the zorgprofessional before a decision (S-11). Terminal. + Ingetrokken, } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 2221201..48ef524 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -217,4 +217,72 @@ public class RegistrationTests Assert.Contains("Afgewezen", rejectEx.Message); Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); } + + [Fact] + public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken() + { + var registration = Registration.Submit("123456782"); + + registration.Withdraw(); + + Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status); + } + + [Fact] + public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken() + { + // A citizen can still pull a registration back while a behandelaar has it in behandeling. + var registration = Registration.Submit("123456782"); + registration.TakeIntoBehandeling(); + + registration.Withdraw(); + + Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status); + } + + [Fact] + public void Withdrawing_needs_no_zaak() + { + // Withdrawal is the citizen's own action and does not depend on the zaak having been opened. + var registration = Registration.Submit("123456782"); + + registration.Withdraw(); + + Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status); + Assert.Null(registration.ZaakUrl); + } + + [Fact] + public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() + { + var registration = Registration.Submit("123456782"); + registration.Withdraw(); + + registration.Withdraw(); + + Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status); + } + + [Fact] + public void Withdrawing_an_approved_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.Approve(); + + var ex = Assert.Throws(() => registration.Withdraw()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } + + [Fact] + public void Withdrawing_a_rejected_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.Reject(); + + var ex = Assert.Throws(() => registration.Withdraw()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); + } } diff --git a/services/domain/Big.Tests/WithdrawRegistrationTests.cs b/services/domain/Big.Tests/WithdrawRegistrationTests.cs new file mode 100644 index 0000000..0506db0 --- /dev/null +++ b/services/domain/Big.Tests/WithdrawRegistrationTests.cs @@ -0,0 +1,60 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +public class WithdrawRegistrationTests +{ + [Fact] + public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() + { + var store = new FakeRegistrationStore(); + var registration = Registration.Submit("123456782"); + store.Seed(registration); + var handler = new WithdrawRegistration(store); + + await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + + var saved = await store.GetAsync(registration.Id); + Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Rejects_a_null_command_without_touching_the_store() + { + var store = new FakeRegistrationStore(); + var handler = new WithdrawRegistration(store); + + await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); + Assert.Equal(0, store.SaveCount); + } + + [Fact] + public async Task Withdrawing_an_unknown_registration_throws() + { + var store = new FakeRegistrationStore(); + var handler = new WithdrawRegistration(store); + + var ex = await Assert.ThrowsAsync( + () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); + Assert.Contains("No registration", ex.Message); + Assert.Equal(0, store.SaveCount); + } + + [Fact] + public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() + { + var store = new FakeRegistrationStore(); + var registration = Registration.Submit("123456782"); + store.Seed(registration); + var handler = new WithdrawRegistration(store); + + await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + + // The second withdrawal is a no-op: the aggregate is not persisted again. + Assert.Equal(1, store.SaveCount); + Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); + } +}