- Uw registratie is ontvangen. Referentie: {{ reference() }}.
-
+ @if (withdrawn()) {
+
+ Uw registratie met referentie {{ reference() }} is ingetrokken.
+
+ } @else {
+
+ Uw registratie is ontvangen. Referentie: {{ reference() }}.
+
+ @if (withdrawFailed()) {
+
+ Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
+
+ }
+
+ }
} @else {
U bent ingelogd met BSN {{ bsn() }}.
@if (failed()) {
diff --git a/apps/self-service/src/app/registration/registration-page.spec.ts b/apps/self-service/src/app/registration/registration-page.spec.ts
index 284f7da..1119762 100644
--- a/apps/self-service/src/app/registration/registration-page.spec.ts
+++ b/apps/self-service/src/app/registration/registration-page.spec.ts
@@ -17,12 +17,22 @@ class FakeAuth extends AuthService {
}
}
-function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
+function providers(
+ post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
+ withdraw = vi.fn().mockReturnValue(of(undefined)),
+) {
return {
post,
+ withdraw,
providers: [
{ provide: AuthService, useClass: FakeAuth },
- { provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
+ {
+ provide: BffApiV1Service,
+ useValue: {
+ postSelfServiceRegistrations: post,
+ postSelfServiceRegistrationsIdWithdraw: withdraw,
+ },
+ },
],
};
}
@@ -56,6 +66,36 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
+ it('offers to withdraw after submitting, and withdrawing confirms', async () => {
+ const { withdraw, providers: p } = providers();
+ await render(RegistrationPage, { providers: p });
+
+ fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
+ await screen.findByText(/ontvangen/i);
+
+ fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
+
+ // The withdrawal is keyed by the reference the submit returned, and the page confirms it.
+ expect(withdraw).toHaveBeenCalledWith('reg-9');
+ expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
+ });
+
+ it('surfaces a withdraw failure and keeps the action available', async () => {
+ const { providers: p } = providers(
+ vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
+ vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
+ );
+ await render(RegistrationPage, { providers: p });
+
+ fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
+ await screen.findByText(/ontvangen/i);
+ fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
+
+ expect(await screen.findByRole('alert')).toBeTruthy();
+ expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
+ expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
+ });
+
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.
diff --git a/apps/self-service/src/app/registration/registration-page.ts b/apps/self-service/src/app/registration/registration-page.ts
index da526be..a5380de 100644
--- a/apps/self-service/src/app/registration/registration-page.ts
+++ b/apps/self-service/src/app/registration/registration-page.ts
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
- * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
+ * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
+ * submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*/
@Component({
selector: 'app-registration-page',
@@ -22,6 +23,9 @@ export class RegistrationPage {
protected readonly reference = signal(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
+ protected readonly withdrawing = signal(false);
+ protected readonly withdrawn = signal(false);
+ protected readonly withdrawFailed = signal(false);
submit(): void {
this.submitting.set(true);
@@ -39,4 +43,24 @@ export class RegistrationPage {
},
});
}
+
+ withdraw(): void {
+ const reference = this.reference();
+ if (!reference) {
+ return;
+ }
+ this.withdrawing.set(true);
+ this.withdrawFailed.set(false);
+ this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
+ next: () => {
+ this.withdrawn.set(true);
+ this.withdrawing.set(false);
+ },
+ // Surface the failure instead of swallowing it: keep the action so the user can retry.
+ error: () => {
+ this.withdrawFailed.set(true);
+ this.withdrawing.set(false);
+ },
+ });
+ }
}
diff --git a/docs/demo-script.md b/docs/demo-script.md
index 8a240f3..5ece867 100644
--- a/docs/demo-script.md
+++ b/docs/demo-script.md
@@ -275,3 +275,26 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
> drives the behandel portal in place of the old admin endpoint.
+
+## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
+
+A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
+withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
+withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
+behandelaar's werkbak.
+
+```text
+# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
+# note the "Referentie" on the confirmation.
+# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
+# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
+# appears — its Beoordelen task was cancelled.
+```
+
+**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
+→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
+interrupting boundary event ends it → the werkbak drops the case.
+
+> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
+> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
+> `Een registratie intrekken` acceptance scenarios and the domain live check.
diff --git a/docs/frontend-decisions.md b/docs/frontend-decisions.md
index e6a7b11..4ceb8e5 100644
--- a/docs/frontend-decisions.md
+++ b/docs/frontend-decisions.md
@@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.
+
+## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
+
+The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
+registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
+
+- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
+ `postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
+ DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
+ the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
+- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
+ switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
+ stays available to retry — mirroring how submit handles its failure rather than swallowing it.
+- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
+ after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
+ browser round-trip is `tests/e2e/withdrawal.spec.ts`.
diff --git a/tests/acceptance/Features/EenRegistratieIntrekken.feature b/tests/acceptance/Features/EenRegistratieIntrekken.feature
new file mode 100644
index 0000000..4322007
--- /dev/null
+++ b/tests/acceptance/Features/EenRegistratieIntrekken.feature
@@ -0,0 +1,22 @@
+# language: en
+# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
+# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
+# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
+# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
+Feature: Een registratie intrekken
+ Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
+ zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
+
+ Scenario: De zorgprofessional trekt zijn eigen registratie in
+ Given a submitted registration with a running process
+ When the zorgprofessional withdraws it
+ Then the withdrawal succeeds
+ And the registration has status "INGETROKKEN"
+ And the running process is cancelled
+
+ Scenario: Een andere zorgprofessional kan de registratie niet intrekken
+ Given a submitted registration with a running process
+ When a different zorgprofessional tries to withdraw it
+ Then the withdrawal is reported not found
+ And the registration has status "INGEDIEND"
+ And the running process is not cancelled
diff --git a/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs b/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
new file mode 100644
index 0000000..bbf3149
--- /dev/null
+++ b/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
@@ -0,0 +1,59 @@
+using Acceptance.Support;
+using Big.Application;
+using Big.Domain;
+using Reqnroll;
+using Xunit;
+
+namespace Acceptance.Steps;
+
+/// Bindings for EenRegistratieIntrekken.feature (S-11). Submits a registration (which
+/// records its process) and then applies the WithdrawRegistration use case against in-memory ports;
+/// one instance per scenario. Scoped to this feature so its "the registration has status" step does
+/// not clash with the identically phrased steps in the other features.
+[Binding]
+[Scope(Feature = "Een registratie intrekken")]
+public sealed class EenRegistratieIntrekkenSteps
+{
+ private const string OwnerBsn = "123456782";
+
+ private readonly InMemoryRegistrationStore _store = new();
+ private readonly InMemoryWorkflowClient _workflow = new();
+ private RegistrationId _id;
+ private WithdrawOutcome _outcome;
+
+ [Given("a submitted registration with a running process")]
+ public async Task GivenASubmittedRegistrationWithARunningProcess()
+ => _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
+
+ [When("the zorgprofessional withdraws it")]
+ public async Task WhenTheZorgprofessionalWithdrawsIt()
+ => _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
+ new WithdrawRegistrationCommand(_id, OwnerBsn));
+
+ [When("a different zorgprofessional tries to withdraw it")]
+ public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
+ => _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
+ new WithdrawRegistrationCommand(_id, "999999990"));
+
+ [Then("the withdrawal succeeds")]
+ public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
+
+ [Then("the withdrawal is reported not found")]
+ public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
+
+ [Then("the registration has status \"(.*)\"")]
+ public async Task ThenTheRegistrationHasStatus(string expected)
+ {
+ var registration = await _store.GetAsync(_id);
+ Assert.NotNull(registration);
+ Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
+ }
+
+ [Then("the running process is cancelled")]
+ public void ThenTheRunningProcessIsCancelled()
+ => Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
+
+ [Then("the running process is not cancelled")]
+ public void ThenTheRunningProcessIsNotCancelled()
+ => Assert.Null(_workflow.WithdrawnProcessInstanceId);
+}
diff --git a/tests/e2e/withdrawal.spec.ts b/tests/e2e/withdrawal.spec.ts
new file mode 100644
index 0000000..7b8336f
--- /dev/null
+++ b/tests/e2e/withdrawal.spec.ts
@@ -0,0 +1,27 @@
+import { expect, test } from '@playwright/test';
+
+// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
+// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
+// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
+// then confirms the registration is ingetrokken.
+test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
+ // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
+ await page.goto('/');
+
+ await page.locator('#username').fill('jan-burger');
+ await page.locator('#password').fill('test123');
+ await page.locator('#kc-login').click();
+
+ await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
+
+ await page.getByRole('button', { name: /indienen/i }).click();
+
+ // The BFF accepted it and the page shows the confirmation with the reference.
+ await expect(page.getByText(/ontvangen/i)).toBeVisible();
+
+ // Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
+ // so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
+ await page.getByRole('button', { name: /trek aanvraag in/i }).click();
+
+ await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
+});