From c8fdfbb699f8381101cc8be7589fd78c40dd84c2 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 21 Jul 2026 13:58:15 +0000 Subject: [PATCH] feat(acl,domain): cancel the ZGW zaak on document-timeout expiry (S-10c, closes #106) (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## S-10c · Close the ZGW zaak on document-timeout expiry (closes #106) Completes the S-10a/S-10b boundary flagged in ADR-0017: when a registration's 30-day document term lapses, the domain now cancels the **ZGW zaak** as well as marking the aggregate `Verlopen`, so OpenZaak and the register no longer diverge. ### What it does On expiry the `ExpireRegistrationWorker` calls the ACL to set the zaak to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (vs the approval `Afgehandeld` + `Geregistreerd`), resolved **by omschrijving** in the ACL — the ACL-first ordering mirrors approval so a failed ZGW call leaves the job for redelivery rather than diverging the two. **Path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd) → aggregate `Verlopen`. ### Layers touched (each red→green) - **ACL gateway** — `SetZaakToCancellationStatusAsync` (Geannuleerd + Vervallen by name); approval now resolves its `Geregistreerd` resultaat by name too (a second resultaattype now exists). - **ACL service/API** — `AclService.CancelZaakAsync` + `POST /annuleringen`. - **Domain** — `IAclClient.CancelZaakAsync` + client; expiry worker cancels the zaak before advancing to `Verlopen`, guarded against redelivery double-cancel. - **Seed** — non-terminal `Geannuleerd` statustype (volgnummer 2; `Afgehandeld` → 3) + `Vervallen` resultaattype, both idempotent by omschrijving and sharing the zaaktype's procestype. - **Verify/integration** — ACL↔OpenZaak integration test (live `Geannuleerd` + resultaat); `run-domain-check.sh` fires the real P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD scenario asserts cancel-on-timeout vs untouched-when-in-time. - **Docs** — ADR-0019 (cancellation modelling decision), demo-script, BACKLOG. ### Design note (ADR-0019) ZGW allows only one eindstatus per zaaktype, so `Geannuleerd` is modelled as a **non-terminal** status (it records a cancellation status + resultaat but does not set `einddatum`). This follows the issue's explicit "distinct statustype + resultaat" outcome; the shared-eindstatus alternative is recorded in the ADR. ### Tests Unit + acceptance all green locally (Acl 38, Big 134, Acceptance 17, Bff 33, EventSubscriber 19). Integration + verify-stack run in CI (need live OpenZaak + selectielijst egress). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/109 --- BACKLOG.md | 4 +- .../adr-0019-zaak-cancellation-on-timeout.md | 81 +++++++++++ docs/demo-script.md | 22 ++- infra/openzaak/seed_catalogus.py | 55 ++++--- infra/run-domain-check.sh | 52 ++++++- services/acl/Acl.Api/Program.cs | 10 ++ services/acl/Acl.Application/AclService.cs | 12 ++ services/acl/Acl.Application/IZaakGateway.cs | 9 ++ .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 57 +++++++- .../Acl.IntegrationTests/OpenZaakFixture.cs | 13 ++ .../OpenZaakGatewayIntegrationTests.cs | 33 +++++ services/acl/Acl.Tests/AclServiceTests.cs | 36 +++++ .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 137 +++++++++++++++++- .../ExpireRegistrationWorker.cs | 10 +- services/domain/Big.Application/Ports.cs | 6 + .../Big.Infrastructure/AclHttpClient.cs | 11 ++ .../ExpireRegistrationWorkerTests.cs | 46 ++++-- services/domain/Big.Tests/Fakes.cs | 10 ++ .../RegistratieVerlopenProcessorTests.cs | 2 +- .../EenDocumentTermijnVerlopen.feature | 2 + .../Steps/EenDocumentTermijnVerlopenSteps.cs | 14 +- .../acceptance/Support/InMemoryDomainPorts.cs | 8 + .../acceptance/Support/InMemoryZaakGateway.cs | 7 + 23 files changed, 591 insertions(+), 46 deletions(-) create mode 100644 docs/architecture/adr-0019-zaak-cancellation-on-timeout.md diff --git a/BACKLOG.md b/BACKLOG.md index c1c90a3..1bd3dca 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -215,7 +215,9 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the #### S-10c · Close the ZGW zaak on document-timeout expiry — #106 -**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a cancellation status (not just the domain aggregate → `Verlopen`). Adds a cancellation statustype/resultaattype to the seed + an ACL method + expiry-worker wiring. Carved from S-10b (ADR-0017/0018). Depends on #103. +**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103. + +**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time. ### S-11 · Withdrawal (Flow 3) diff --git a/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md b/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md new file mode 100644 index 0000000..2b5f978 --- /dev/null +++ b/docs/architecture/adr-0019-zaak-cancellation-on-timeout.md @@ -0,0 +1,81 @@ +# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name + +- **Status:** Accepted +- **Date:** 2026-07-21 +- **Deciders:** Respellion engineering +- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and + reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018. + +## Context + +ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day +document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left +open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the +two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL +(§8.1, the only code that talks to ZGW). + +The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close. +The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In +ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what +closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule. + +## Decision + +**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen` +resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving +(name) rather than by position or the eindstatus flag alone.** + +- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld` + eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path + resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the + approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the + zaaktype's single `selectielijstProcestype` so they validate on publish. +- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd` + + `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat + before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as + `AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as + constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1). +- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first + is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd` + by omschrijving. Its statustype resolution is unchanged (still the eindstatus). +- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before** + advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is + redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing + open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a + registration that lapsed before its zaak was opened has nothing to cancel. + +## Consequences + +**Positive** + +- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation. +- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so + the change is additive and §8 stays clean (only the ACL talks to ZGW). +- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd` + with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is + cancelled end-to-end. + +**Negative / costs** + +- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a + cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the + domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to + fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a + `Vervallen` resultaat) is a possible follow-up. +- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string + constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and + is documented in the gateway. +- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its + OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run. + +## Alternatives considered + +- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs + cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but + the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's + explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW. +- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per + zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with + the approval eindstatus resolution. +- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is + ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak". diff --git a/docs/demo-script.md b/docs/demo-script.md index 3004a40..20e3420 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -461,4 +461,24 @@ make verify-acl # → "Storing a diploma creates a real informatieobject `ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` + `zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018). -> Setting the ZGW zaak to a cancellation status on 30-day expiry is a follow-up (S-10c, #106). +## S-10c — the ZGW zaak is cancelled when the document term lapses (#106) + +When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` — +it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set +to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the +approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019). + +```bash +# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat +# against a live OpenZaak: +make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat" +# +# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts +# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd): +make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak" +``` + +**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL +`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to +`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the +job for redelivery rather than diverging the two (ADR-0019). diff --git a/infra/openzaak/seed_catalogus.py b/infra/openzaak/seed_catalogus.py index ed4ad67..9aae3fb 100644 --- a/infra/openzaak/seed_catalogus.py +++ b/infra/openzaak/seed_catalogus.py @@ -10,7 +10,7 @@ Creates (if absent): Auth uses the JWT client provisioned by setup_configuration (see ADR-0002). Stdlib only — no pip deps. Re-running is safe (matches existing by identifier). """ -import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request +import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request BASE = os.environ.get("OZ_BASE", "http://localhost:8000") CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") @@ -77,8 +77,12 @@ def publish_zaaktype(zt): Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's `selectielijstProcestype`, plus a `resultaattypeomschrijving`. """ + # Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between + # them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld + # eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a + # stale local stack must reset its OpenZaak volumes for the renumbering to take effect. have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")} - for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]: + for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]: if volgnummer not in have_st: st, body = api("POST", "/statustypen", { "omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer}) @@ -95,25 +99,42 @@ def publish_zaaktype(zt): sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}") print("create roltype Aanvrager") - if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"): - print("skip resultaattype Geregistreerd") + # Two resultaattypen, keyed by omschrijving so each is created independently (idempotent): + # "Geregistreerd" — the approval outcome (S-09b) + # "Vervallen" — the document-timeout cancellation outcome (S-10c) + # Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two + # Selectielijst resultaten from a single procestype and set that procestype on the zaaktype. + have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")} + wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")] + if all(naam in have_rt for naam, _ in wanted): + print("skip resultaattypen Geregistreerd + Vervallen") else: - resultaat = selectielijst("/resultaten?pageSize=1")["results"][0] + # Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so + # both klassen validate against the zaaktype's selectielijstProcestype. + procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"] + resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"] + if len(resultaten) < len(wanted): + sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen") omschrijvingen = selectielijst("/resultaattypeomschrijvingen") - oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"] - # The selectielijstklasse and the zaaktype must share a procestype. - st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]}) + oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"] + + st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype}) if st != 200: sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}") - st, body = api("POST", "/resultaattypen", { - "zaaktype": zt["url"], "omschrijving": "Geregistreerd", - "resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"], - "archiefnominatie": "blijvend_bewaren", - "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"}, - }) - if st != 201: - sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}") - print("create resultaattype Geregistreerd") + + for i, (naam, archiefnominatie) in enumerate(wanted): + if naam in have_rt: + print(f"skip resultaattype {naam}") + continue + st, body = api("POST", "/resultaattypen", { + "zaaktype": zt["url"], "omschrijving": naam, + "resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"], + "archiefnominatie": archiefnominatie, + "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"}, + }) + if st != 201: + sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}") + print(f"create resultaattype {naam}") if zt.get("concept", True): st, body = api("POST", f"{zt['url']}/publish") diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 359f65c..3c360d6 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -309,10 +309,11 @@ done [ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead" -# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above — -# its documents never arrive. We fire its 30-day boundary timer early via the management API; the -# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The -# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ──────────── +# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block +# above — its documents never arrive. We fire its 30-day boundary timer early via the management API; +# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. +# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the +# registration to VERLOPEN (ADR-0017). ───────────────────────────────────────────────────────────── echo ">> submitting a registration to let its document term lapse" locv="$(docker run --rm --network "$net" curlimages/curl:latest \ -fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \ @@ -354,4 +355,47 @@ for _ in $(seq 1 30); do done [ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } echo "OK — the 30-day document timer expired the registration to VERLOPEN" + +# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN +# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with +# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack). +zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)" +[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; } +echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak" + +read_zaak_status() { + # -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty). + docker run --rm -i --network "$net" \ + -e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \ + -e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \ + python:3-slim python - "$1" <<'PY' +import base64, hashlib, hmac, json, os, sys, time, urllib.request +cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"] +b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") +def token(): + hdr = {"alg": "HS256", "typ": "JWT"} + pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"} + seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode()) + return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode() +def get(url): + req = urllib.request.Request(url, headers={ + "Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) +zaak = get(sys.argv[1]) +status_url = zaak.get("status") +if not status_url: + print(""); sys.exit(0) +print(get(get(status_url)["statustype"]).get("omschrijving", "")) +PY +} + +geannuleerd="" +for _ in $(seq 1 15); do + oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)" + [ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; } + sleep 2 +done +[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } +echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak" exit 0 diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 9581455..1b1bf7f 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -32,6 +32,14 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella return Results.NoContent(); }); +// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype +// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1). +app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) => +{ + await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct); + return Results.NoContent(); +}); + // Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich // the read projection without reading ZGW itself (§8.1, #78). app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) => @@ -55,6 +63,8 @@ public sealed record OpenZaakRequest(string Bsn, string Reference); public sealed record SetStatusRequest(string ZaakUrl); +public sealed record CancelZaakRequest(string ZaakUrl); + public sealed record ZaakReferenceRequest(string ZaakUrl); public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType); diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index f692041..6f00fef 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -30,6 +30,18 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); } + /// + /// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's + /// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which + /// statustype/resultaat means "cancelled" (§8.1). + /// + public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + } + /// The zaak's reference (its ZGW identificatie), for the read projection (#78). public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) { diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 73903f0..1e71337 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -13,6 +13,15 @@ public interface IZaakGateway /// Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + /// + /// Set the given zaak to the cancellation statustype ("Geannuleerd") and record the + /// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term + /// lapsed" (S-10c). Distinct from (approval): the gateway + /// resolves both the cancellation statustype and resultaattype from the catalogus by their + /// omschrijving, POSTs the resultaat then the status, dated . + /// + Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + /// Read the zaak's identificatie — the public-safe reference the register shows. /// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78). Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default); diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 18907ab..5f33ba8 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -8,6 +8,12 @@ namespace Acl.Infrastructure; /// The only code that talks to OpenZaak's Zaken API (ADR-0001). public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway { + // The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These + // omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py). + private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome + private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c) + private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c) + public async Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); @@ -48,7 +54,9 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : ArgumentNullException.ThrowIfNull(zaaktypeUrl); var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); - var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct); + // Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking + // the first would be ambiguous (the Zaken API does not guarantee order). + var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct); // OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat // ("resultaat-does-not-exist"), so record the resultaat first, then the status. @@ -62,6 +70,27 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : "Setting the zaak status", ct); } + public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + ArgumentNullException.ThrowIfNull(zaaktypeUrl); + + // Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd + // is a non-terminal statustype, so it is never the eindstatus the approval path resolves). + var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct); + var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct); + + // As with approval, OpenZaak wants the resultaat recorded before the status. + await PostAsync("/zaken/api/v1/resultaten", + new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()), + "Setting the zaak cancellation resultaat", ct); + + await PostAsync("/zaken/api/v1/statussen", + new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(), + datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")), + "Setting the zaak cancellation status", ct); + } + public async Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); @@ -174,13 +203,23 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return new Uri(eindstatus.Url); } - /// Resolve the zaaktype's resultaattype from the catalogus (the seed defines one). - private async Task ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct) + /// Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd"). + private async Task ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct) + { + var page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct); + var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving) + ?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}"); + return new Uri(match.Url); + } + + /// Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines + /// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation). + private async Task ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct) { var page = await GetCatalogusAsync("resultaattypen", zaaktypeUrl, "resultaattypen", ct); - var resultaattype = (page.Results ?? []).FirstOrDefault() - ?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}"); - return new Uri(resultaattype.Url); + var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving) + ?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}"); + return new Uri(match.Url); } // GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published). @@ -224,7 +263,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record StatustypeDto( [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("volgnummer")] int Volgnummer, - [property: JsonPropertyName("isEindstatus")] bool IsEindstatus); + [property: JsonPropertyName("isEindstatus")] bool IsEindstatus, + [property: JsonPropertyName("omschrijving")] string? Omschrijving); private sealed record ResultaatDto( [property: JsonPropertyName("zaak")] string Zaak, @@ -234,7 +274,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : [property: JsonPropertyName("results")] IReadOnlyList? Results); private sealed record ResultaattypeDto( - [property: JsonPropertyName("url")] string Url); + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("omschrijving")] string? Omschrijving); private sealed record CreatedDto( [property: JsonPropertyName("url")] string Url); diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs index 32dece6..0407918 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs @@ -114,6 +114,19 @@ public sealed class OpenZaakFixture : IDisposable return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}"); } + /// Resolve a statustype by its omschrijving (e.g. the S-10c "Geannuleerd" cancellation status). + public async Task FindStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct = default) + { + var query = new Uri(BaseUrl, + "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + var page = await GetJsonAsync(query, ct); + foreach (var st in page.GetProperty("results").EnumerateArray()) + if (st.TryGetProperty("omschrijving", out var o) && o.GetString() == omschrijving) + return new Uri(st.GetProperty("url").GetString()!); + + throw new InvalidOperationException($"No '{omschrijving}' statustype for zaaktype {zaaktypeUrl}"); + } + // A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here // rather than reusing Acl.Infrastructure's internal minter to keep that internal. private string MintToken() diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index f9c4c13..d59d0d9 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -75,6 +75,39 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString()); } + [Fact] + public async Task Cancelling_a_zaak_records_the_geannuleerd_status_and_a_resultaat() + { + var zaaktype = await stack.FindPublishedBigZaaktypeAsync(); + Assert.True(zaaktype is not null, + "No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " + + "seed it with OZ_PUBLISH=1 (`make integration` does this)."); + + var gateway = new OpenZaakGateway(stack.Http, stack.Options); + var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest( + Bronorganisatie: "517439943", + VerantwoordelijkeOrganisatie: "517439943", + Vertrouwelijkheidaanduiding: "openbaar", + Zaaktype: zaaktype!, + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), + Identificatie: Guid.NewGuid().ToString())); + + await gateway.SetZaakToCancellationStatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); + + // The zaak's current status is the Geannuleerd statustype — distinct from the approval eindstatus. + var zaak = await stack.GetZaakAsync(zaakUrl); + var statusUrl = zaak.GetProperty("status").GetString(); + Assert.False(string.IsNullOrEmpty(statusUrl), "the cancelled zaak has no current status"); + + var status = await stack.GetJsonAsync(new Uri(statusUrl!)); + var geannuleerd = await stack.FindStatustypeByOmschrijvingAsync(zaaktype!, "Geannuleerd"); + Assert.Equal(geannuleerd.ToString(), status.GetProperty("statustype").GetString()); + + // ...and a resultaat is recorded (OpenZaak requires it before a closing/terminal status). + Assert.False(string.IsNullOrEmpty(zaak.GetProperty("resultaat").GetString()), + "the cancelled zaak has no resultaat"); + } + [Fact] public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak() { diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index fe2a81a..01cbcef 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -23,6 +23,14 @@ public class AclServiceTests return Task.CompletedTask; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled; + + public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } + public Uri? ReadReferenceFor; public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) @@ -126,6 +134,34 @@ public class AclServiceTests Assert.Null(gateway.Approved); } + [Fact] + public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today() + { + var gateway = new FakeGateway(); + var defaults = Defaults(); + var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + await service.CancelZaakAsync(zaak); + + Assert.NotNull(gateway.Cancelled); + Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak); + Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype); + Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum); + // Cancellation must not touch the approval path. + Assert.Null(gateway.Approved); + } + + [Fact] + public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + + await Assert.ThrowsAsync(() => service.CancelZaakAsync(null!)); + Assert.Null(gateway.Cancelled); + } + [Fact] public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url() { diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index e58c050..f167f75 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -173,7 +173,8 @@ public class OpenZaakGatewayTests private sealed class OzRoutes { public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true); - public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}"""; + public string ResultaattypenJson { get; init; } = + """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}"""; public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK; public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK; public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created; @@ -251,6 +252,138 @@ public class OpenZaakGatewayTests Assert.True(status.Length > 0); } + [Fact] + public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist() + { + // Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the + // Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving. + var rec = new Recorder(); + var twoResultaattypen = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}, + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"} + ]} + """; + + await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"", + rec.Sent("/resultaten").Body); + } + + // --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) --- + + // A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the + // Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and + // "Vervallen" by omschrijving, never the approval pair. + private const string CancellationStatustypenJson = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false}, + {"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false}, + {"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true} + ]} + """; + + private const string CancellationResultaattypenJson = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}, + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"} + ]} + """; + + [Fact] + public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, new OzRoutes + { + StatustypenJson = CancellationStatustypenJson, + ResultaattypenJson = CancellationResultaattypenJson, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + // Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status). + Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen")); + + var resultaat = rec.Sent("/resultaten"); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body); + // The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd). + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body); + + var status = rec.Sent("/statussen"); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body); + // The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval). + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body); + } + + [Fact] + public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes + { + // Only the approval statustypen — no "Geannuleerd". + StatustypenJson = StatustypenPage(withEindstatusFlag: true), + ResultaattypenJson = CancellationResultaattypenJson, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Geannuleerd", ex.Message); + } + + [Fact] + public async Task Cancelling_rejects_a_null_zaak_without_calling_openzaak() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + await Assert.ThrowsAsync(() => + Gateway(handler).SetZaakToCancellationStatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4))); + } + + [Fact] + public async Task Cancelling_rejects_a_null_zaaktype_without_calling_openzaak() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + await Assert.ThrowsAsync(() => + Gateway(handler).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4))); + } + + [Fact] + public async Task Cancelling_surfaces_the_failure_when_recording_the_resultaat_is_rejected() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes + { + StatustypenJson = CancellationStatustypenJson, + ResultaattypenJson = CancellationResultaattypenJson, + ResultaatPostStatus = HttpStatusCode.BadRequest, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("cancellation resultaat", ex.Message); + // It fails on the resultaat, before it ever posts the status. + Assert.Equal(-1, rec.IndexOf("/statussen")); + } + + [Fact] + public async Task Cancelling_surfaces_the_failure_when_recording_the_status_is_rejected() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes + { + StatustypenJson = CancellationStatustypenJson, + ResultaattypenJson = CancellationResultaattypenJson, + StatusPostStatus = HttpStatusCode.BadRequest, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("cancellation status", ex.Message); + } + [Fact] public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged() { @@ -325,7 +458,7 @@ public class OpenZaakGatewayTests Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); - Assert.Contains("No resultaattypen found", ex.Message); + Assert.Contains("'Geregistreerd' resultaattype", ex.Message); // Resolved the eindstatus + queried resultaattypen, but posted nothing. Assert.Equal(-1, rec.IndexOf("/resultaten")); Assert.Equal(-1, rec.IndexOf("/statussen")); diff --git a/services/domain/Big.Application/ExpireRegistrationWorker.cs b/services/domain/Big.Application/ExpireRegistrationWorker.cs index 05f8b94..b351355 100644 --- a/services/domain/Big.Application/ExpireRegistrationWorker.cs +++ b/services/domain/Big.Application/ExpireRegistrationWorker.cs @@ -9,7 +9,7 @@ namespace Big.Application; /// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors /// . /// -public sealed class ExpireRegistrationWorker(IRegistrationStore store) +public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClient acl) { /// /// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose @@ -31,6 +31,14 @@ public sealed class ExpireRegistrationWorker(IRegistrationStore store) if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) return; + // Cancel the ZGW zaak before advancing the aggregate (mirrors the approval path): if the ACL + // call fails it throws, the aggregate stays open, and the job is redelivered (§8.6) — rather + // than leaving the aggregate VERLOPEN while the zaak stays open. The status guard above stops a + // redelivered job from cancelling the zaak twice (a second resultaat would be a 400). A + // registration expired before its zaak was opened has nothing to cancel. + if (registration.ZaakUrl is not null) + await acl.CancelZaakAsync(registration.ZaakUrl, ct); + registration.Expire(); await store.SaveAsync(registration, ct); } diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 9d2c5e7..1a88018 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -59,6 +59,12 @@ public interface IAclClient /// zaak (§8.1). Returns the stored document's URL. /// Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default); + + /// + /// Cancel the zaak on document-timeout expiry (S-10c): the 30-day document term lapsed, so the ACL + /// translates this to the ZGW cancellation status/resultaat. The domain never names statustypen. + /// + Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index 22c235c..0d387ef 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -31,6 +31,15 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli response.EnsureSuccessStatusCode(); } + public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var response = await http.PostAsJsonAsync( + new Uri(options.BaseUrl, "annuleringen"), new CancelZaakRequest(zaakUrl.ToString()), ct); + response.EnsureSuccessStatusCode(); + } + public async Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); @@ -56,6 +65,8 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + private sealed record CancelZaakRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + private sealed record StoreDocumentRequest( [property: JsonPropertyName("zaakUrl")] string ZaakUrl, [property: JsonPropertyName("contentBase64")] string ContentBase64, diff --git a/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs index dafc963..e9b4379 100644 --- a/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs +++ b/services/domain/Big.Tests/ExpireRegistrationWorkerTests.cs @@ -10,10 +10,13 @@ public class ExpireRegistrationWorkerTests { private const string Bsn = "123456782"; - private static Registration Submitted(string processInstanceId = "proc-1") + // By the time the 30-day document timer fires, the zaak was opened long ago (OpenZaakAanmaken runs + // early in the flow), so a timed-out registration carries a zaak the worker can cancel. + private static Registration Submitted(string processInstanceId = "proc-1", Uri? zaakUrl = null) { var registration = Registration.Submit(Bsn); registration.RecordProcessStarted(processInstanceId); + registration.AttachZaak(zaakUrl ?? FakeAclClient.DefaultZaakUrl); return registration; } @@ -24,7 +27,7 @@ public class ExpireRegistrationWorkerTests var registration = Submitted(); store.Seed(registration); - await new ExpireRegistrationWorker(store).HandleAsync( + await new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync( new RegistratieVerlopenJob("job-7", registration.Id)); var saved = await store.GetAsync(registration.Id); @@ -33,37 +36,60 @@ public class ExpireRegistrationWorkerTests } [Fact] - public async Task An_already_verlopen_registration_is_not_persisted_again() + public async Task Cancels_the_zaak_via_the_acl_when_expiring_a_still_open_registration() { - // A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again. + // S-10c: expiring the aggregate is not enough — the ZGW zaak must also be set to its + // cancellation status, which the ACL owns (§8.1). The worker hands the ACL the zaak URL. + var store = new FakeRegistrationStore(); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/timed-out"); + var registration = Submitted(zaakUrl: zaak); + store.Seed(registration); + var acl = new FakeAclClient(); + + await new ExpireRegistrationWorker(store, acl).HandleAsync( + new RegistratieVerlopenJob("job-7", registration.Id)); + + Assert.Equal(1, acl.CancelCallCount); + Assert.Equal(zaak, acl.CancelledZaakUrl); + } + + [Fact] + public async Task An_already_verlopen_registration_is_not_persisted_again_and_the_zaak_is_not_recancelled() + { + // A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again — and + // the ACL is not asked to cancel the zaak a second time (posting a second resultaat would 400). var store = new FakeRegistrationStore(); var registration = Submitted(); registration.Expire(); store.Seed(registration); + var acl = new FakeAclClient(); - await new ExpireRegistrationWorker(store).HandleAsync( + await new ExpireRegistrationWorker(store, acl).HandleAsync( new RegistratieVerlopenJob("job-7", registration.Id)); Assert.Equal(0, store.SaveCount); + Assert.Equal(0, acl.CancelCallCount); Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status); } [Fact] - public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes() + public async Task An_already_resolved_registration_is_left_alone_and_the_zaak_is_not_cancelled() { // Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is // already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the // aggregate's invariant; the worker must instead no-op (and let the job complete), not throw - // into a redelivery loop. + // into a redelivery loop — and it must not cancel the zaak of a registration it didn't expire. var store = new FakeRegistrationStore(); var registration = Submitted(); registration.Withdraw(); store.Seed(registration); + var acl = new FakeAclClient(); - await new ExpireRegistrationWorker(store).HandleAsync( + await new ExpireRegistrationWorker(store, acl).HandleAsync( new RegistratieVerlopenJob("job-7", registration.Id)); Assert.Equal(0, store.SaveCount); + Assert.Equal(0, acl.CancelCallCount); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); } @@ -73,12 +99,12 @@ public class ExpireRegistrationWorkerTests var store = new FakeRegistrationStore(); await Assert.ThrowsAsync(() => - new ExpireRegistrationWorker(store).HandleAsync( + new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync( new RegistratieVerlopenJob("job-7", RegistrationId.New()))); } [Fact] public async Task Rejects_a_null_job() => await Assert.ThrowsAsync(() => - new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!)); + new ExpireRegistrationWorker(new FakeRegistrationStore(), new FakeAclClient()).HandleAsync(null!)); } diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index ef539ef..de2a029 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -119,4 +119,14 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient StoredDiploma = (zaakUrl, content, fileName, contentType); return Task.FromResult(DefaultDocumentUrl); } + + public Uri? CancelledZaakUrl { get; private set; } + public int CancelCallCount { get; private set; } + + public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + CancelCallCount++; + CancelledZaakUrl = zaakUrl; + return Task.CompletedTask; + } } diff --git a/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs index e13c950..5efcd57 100644 --- a/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs +++ b/services/domain/Big.Tests/RegistratieVerlopenProcessorTests.cs @@ -30,7 +30,7 @@ public class RegistratieVerlopenProcessorTests } } - private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store); + private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store, new FakeAclClient()); [Fact] public async Task Acquires_a_job_expires_the_registration_and_completes_the_job() diff --git a/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature index 7ec02bf..9158d90 100644 --- a/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature +++ b/tests/acceptance/Features/EenDocumentTermijnVerlopen.feature @@ -14,6 +14,7 @@ Feature: Een documenttermijn laten verlopen When the 30-day document timer fires And the document-timeout worker runs Then the registration is verlopen + And the zaak is cancelled in ZGW Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen Given a registration parked at the WachtOpDocumenten task @@ -21,3 +22,4 @@ Feature: Een documenttermijn laten verlopen And the 30-day document timer fires And the document-timeout worker runs Then the registration is not verlopen + And the zaak is not cancelled in ZGW diff --git a/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs index 402cfde..59ebdee 100644 --- a/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs +++ b/tests/acceptance/Steps/EenDocumentTermijnVerlopenSteps.cs @@ -17,8 +17,11 @@ namespace Acceptance.Steps; [Scope(Feature = "Een documenttermijn laten verlopen")] public sealed class EenDocumentTermijnVerlopenSteps { + private static readonly Uri ZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-timeout"); + private readonly InMemoryDocumentTimeoutClient _flowable = new(); private readonly Support.InMemoryRegistrationStore _store = new(); + private readonly InMemoryAclClient _acl = new(); private Registration _registration = null!; private string _processInstanceId = ""; @@ -26,6 +29,9 @@ public sealed class EenDocumentTermijnVerlopenSteps public async Task GivenARegistrationParkedAtWachtOpDocumenten() { _registration = Registration.Submit("123456782"); + // By the time it parks at WachtOpDocumenten its zaak has been opened (OpenZaakAanmaken runs + // earlier), so a timeout has a zaak to cancel. + _registration.AttachZaak(ZaakUrl); await _store.SaveAsync(_registration); _processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id); } @@ -39,7 +45,7 @@ public sealed class EenDocumentTermijnVerlopenSteps [When("the document-timeout worker runs")] public async Task WhenTheTimeoutWorkerRuns() => await new RegistratieVerlopenProcessor( - _flowable, new ExpireRegistrationWorker(_store), + _flowable, new ExpireRegistrationWorker(_store, _acl), NullLogger.Instance).PumpOnceAsync(5); [Then("the registration is verlopen")] @@ -49,4 +55,10 @@ public sealed class EenDocumentTermijnVerlopenSteps [Then("the registration is not verlopen")] public async Task ThenTheRegistrationIsNotVerlopen() => Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status); + + [Then("the zaak is cancelled in ZGW")] + public void ThenTheZaakIsCancelled() => Assert.Equal(ZaakUrl, _acl.CancelledZaakUrl); + + [Then("the zaak is not cancelled in ZGW")] + public void ThenTheZaakIsNotCancelled() => Assert.Null(_acl.CancelledZaakUrl); } diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index c63b05e..0400d19 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -60,6 +60,14 @@ public sealed class InMemoryAclClient : IAclClient return Task.CompletedTask; } + public Uri? CancelledZaakUrl { get; private set; } + + public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + CancelledZaakUrl = zaakUrl; + return Task.CompletedTask; + } + public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; } public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index e9e205d..d16d353 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -12,6 +12,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway public ZaakRequest? Captured { get; private set; } public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; } public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { @@ -25,6 +26,12 @@ public sealed class InMemoryZaakGateway : IZaakGateway return Task.CompletedTask; } + public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) => Task.FromResult("ACC-REF-1");