From 98abba8b22b1c1db313fe6735c30b33bbbe96549 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 10:43:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?test(domain):=20a=20citizen=20can=20withdra?= =?UTF-8?q?w=20an=20open=20registration=20=E2=86=92=20INGETROKKEN=20(refs?= =?UTF-8?q?=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdraw is allowed from INGEDIEND or IN_BEHANDELING, needs no zaak, is idempotent, and is rejected once the registration has been decided (INGESCHREVEN/AFGEWEZEN). The WithdrawRegistration handler loads, withdraws, and persists; a repeated withdrawal is a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/Big.Tests/RegistrationTests.cs | 68 +++++++++++++++++++ .../Big.Tests/WithdrawRegistrationTests.cs | 60 ++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 services/domain/Big.Tests/WithdrawRegistrationTests.cs 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); + } +} -- 2.49.1 From d966a198fb9287903f28c9df3a4018ddec0dbf4e Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 10:53:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(domain):=20withdraw=20a=20registration?= =?UTF-8?q?=20=E2=86=92=20INGETROKKEN=20via=20POST=20/registrations/{id}/w?= =?UTF-8?q?ithdraw=20(refs=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Ingetrokken terminal status, Registration.Withdraw() (open-only, idempotent), the WithdrawRegistration handler, and the domain endpoint. Cancelling the running Flowable process (so the case leaves the werkbak) and the owner-scoped BFF/self-service action are follow-up sub-slices (S-11b/S-11c). Co-Authored-By: Claude Opus 4.8 (1M context) --- 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 +++- 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 services/domain/Big.Application/WithdrawRegistration.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, } -- 2.49.1