diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs
index 31385d9..d601366 100644
--- a/services/acl/Acl.Api/Program.cs
+++ b/services/acl/Acl.Api/Program.cs
@@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
});
+// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
+// URL; the ACL owns the ZGW statustype resolution (§8.1).
+app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
+{
+ await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
+ return Results.NoContent();
+});
+
app.Run();
public sealed record OpenZaakRequest(string Bsn);
+public sealed record SetStatusRequest(string ZaakUrl);
+
public partial class Program;
diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs
index e285ee7..f9b866a 100644
--- a/services/acl/Acl.Application/AclService.cs
+++ b/services/acl/Acl.Application/AclService.cs
@@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.OpenZaakAsync(request, ct);
}
+
+ ///
+ /// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
+ /// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
+ ///
+ public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(zaakUrl);
+
+ return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
+ }
}
diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs
index 1962488..c608c7a 100644
--- a/services/acl/Acl.Application/IZaakGateway.cs
+++ b/services/acl/Acl.Application/IZaakGateway.cs
@@ -5,4 +5,11 @@ namespace Acl.Application;
public interface IZaakGateway
{
Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
+
+ ///
+ /// Set the given zaak to the eindstatus (final statustype) of the supplied zaaktype —
+ /// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
+ /// the catalogus and POSTs a status against the zaak, dated .
+ ///
+ Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
}
diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
index f9cce31..10a3c3f 100644
--- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
+++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
@@ -41,6 +41,56 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(created.Url);
}
+ public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(zaakUrl);
+ ArgumentNullException.ThrowIfNull(zaaktypeUrl);
+
+ var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
+
+ using var message = new HttpRequestMessage(
+ HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
+ {
+ Content = JsonContent.Create(new StatusDto(
+ zaakUrl.ToString(),
+ eindstatus.ToString(),
+ // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
+ datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
+ };
+ message.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
+ // The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
+ // body so uwsgi gets a Content-Length instead of a chunked body.
+ await message.Content.LoadIntoBufferAsync(ct);
+
+ using var response = await http.SendAsync(message, ct);
+ response.EnsureSuccessStatusCode();
+ }
+
+ /// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.
+ private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
+ {
+ var query = new Uri(options.BaseUrl,
+ "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
+ using var message = new HttpRequestMessage(HttpMethod.Get, query);
+ message.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
+
+ using var response = await http.SendAsync(message, ct);
+ response.EnsureSuccessStatusCode();
+
+ var page = await response.Content.ReadFromJsonAsync(ct)
+ ?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
+ var results = page.Results ?? [];
+
+ // OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
+ // highest volgnummer if the flag is absent.
+ var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
+ ?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
+ ?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
+ return new Uri(eindstatus.Url);
+ }
+
private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
@@ -50,4 +100,17 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url);
+
+ private sealed record StatusDto(
+ [property: JsonPropertyName("zaak")] string Zaak,
+ [property: JsonPropertyName("statustype")] string Statustype,
+ [property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
+
+ private sealed record StatustypePage(
+ [property: JsonPropertyName("results")] IReadOnlyList? Results);
+
+ private sealed record StatustypeDto(
+ [property: JsonPropertyName("url")] string Url,
+ [property: JsonPropertyName("volgnummer")] int Volgnummer,
+ [property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
}
diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
index bcdfdcc..a743913 100644
--- a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
+++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
@@ -65,6 +65,43 @@ public sealed class OpenZaakFixture : IDisposable
return JsonDocument.Parse(json).RootElement.Clone();
}
+ /// GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.
+ public async Task GetJsonAsync(Uri url, CancellationToken ct = default)
+ {
+ using var message = new HttpRequestMessage(HttpMethod.Get, url);
+ message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
+
+ using var response = await Http.SendAsync(message, ct);
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync(ct);
+ return JsonDocument.Parse(json).RootElement.Clone();
+ }
+
+ /// The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.
+ public async Task FindEindstatustypeAsync(Uri zaaktypeUrl, 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);
+ var results = page.GetProperty("results");
+
+ Uri? fallback = null;
+ var highest = int.MinValue;
+ foreach (var st in results.EnumerateArray())
+ {
+ if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
+ return new Uri(st.GetProperty("url").GetString()!);
+ var volgnummer = st.GetProperty("volgnummer").GetInt32();
+ if (volgnummer > highest)
+ {
+ highest = volgnummer;
+ fallback = new Uri(st.GetProperty("url").GetString()!);
+ }
+ }
+
+ return fallback ?? throw new InvalidOperationException($"No statustypen 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 16f2b7e..dfe88b2 100644
--- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs
+++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs
@@ -42,4 +42,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
}
+
+ [Fact]
+ public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
+ {
+ 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)));
+
+ await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
+
+ // The zaak now carries a current status, and it is the zaaktype's eindstatus.
+ var zaak = await stack.GetZaakAsync(zaakUrl);
+ var statusUrl = zaak.GetProperty("status").GetString();
+ Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
+
+ var status = await stack.GetJsonAsync(new Uri(statusUrl!));
+ var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
+ Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
+ }
}