From dc532dee00c471d8095500c08309c5c44b272b43 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:51:11 +0200 Subject: [PATCH 1/9] test(domain): herregistratie inscription date + reminder-due rule (refs #18) The Registration aggregate gains the herregistratie clock: Approve now records the inscription moment, from which the herregistratie deadline and a reminder-due rule are derived. Members are stubbed so the suite compiles and the new assertions fail; the green commit implements them. Approve's signature gains the inscription moment (callers now supply 'now' from an injected TimeProvider). refs #18 --- services/domain/Big.Api/Program.cs | 4 + .../Big.Application/ApproveRegistration.cs | 4 +- .../Big.Application/BeoordeelRegistratie.cs | 4 +- services/domain/Big.Domain/Registration.cs | 41 ++++++++- .../Big.Tests/ApproveRegistrationTests.cs | 10 +-- .../Big.Tests/BeoordeelRegistratieTests.cs | 18 ++-- .../InMemoryRegistrationStoreTests.cs | 2 +- .../RegistrationHerregistratieTests.cs | 90 +++++++++++++++++++ .../domain/Big.Tests/RegistrationTests.cs | 21 +++-- 9 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 services/domain/Big.Tests/RegistrationHerregistratieTests.cs diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 5d0db51..03b8a01 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -15,6 +15,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() // The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009). builder.Services.AddSingleton(); +// The system clock, injected wherever a use case needs "now" (e.g. stamping the inscription moment +// on approval, S-17). Injected as TimeProvider so tests can substitute a fixed clock. +builder.Services.AddSingleton(TimeProvider.System); + // The Workflow Client is one type behind two ports (start side + worker side); both resolve to the // same HttpClient-backed implementation — the only code that talks to Flowable (§8.2). builder.Services.AddHttpClient(); diff --git a/services/domain/Big.Application/ApproveRegistration.cs b/services/domain/Big.Application/ApproveRegistration.cs index ac6e32e..d1cf997 100644 --- a/services/domain/Big.Application/ApproveRegistration.cs +++ b/services/domain/Big.Application/ApproveRegistration.cs @@ -12,7 +12,7 @@ public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId); /// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition /// keeps the domain's own view consistent. /// -public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl) +public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl, TimeProvider clock) { public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default) { @@ -30,7 +30,7 @@ public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); await acl.ApproveZaakAsync(registration.ZaakUrl, ct); - registration.Approve(); + registration.Approve(clock.GetUtcNow()); await store.SaveAsync(registration, ct); } } diff --git a/services/domain/Big.Application/BeoordeelRegistratie.cs b/services/domain/Big.Application/BeoordeelRegistratie.cs index 8ba8e3f..48f02d0 100644 --- a/services/domain/Big.Application/BeoordeelRegistratie.cs +++ b/services/domain/Big.Application/BeoordeelRegistratie.cs @@ -25,7 +25,7 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, /// decisions are idempotent — a repeated or redelivered decision that matches the current terminal /// state is a no-op, so the ACL is not called and the task not completed twice. /// -public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks) +public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock) { public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default) { @@ -44,7 +44,7 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac throw new InvalidOperationException( $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); await acl.ApproveZaakAsync(registration.ZaakUrl, ct); - registration.Approve(); + registration.Approve(clock.GetUtcNow()); break; case BeoordelingsBesluit.Afwijzen: diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index a3b3503..3aea662 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -92,11 +92,12 @@ public sealed class Registration /// /// Approve the registration — the behandelaar's decision to enter it in the register. Advances a - /// submitted or in-behandeling registration to . - /// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that - /// has already been decided cannot be approved again. + /// submitted or in-behandeling registration to and + /// records as the moment of inscription, which starts the + /// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via + /// the ACL); a registration that has already been decided cannot be approved again. /// - public void Approve() + public void Approve(DateTimeOffset ingeschrevenOp) { if (ZaakUrl is null) throw new InvalidOperationException( @@ -104,8 +105,40 @@ public sealed class Registration RequireOpenForDecision(nameof(Approve)); Status = RegistrationStatus.Ingeschreven; + // RED stub: the inscription moment is not stored yet. } + // --- Herregistratie (S-17) — RED stubs, implemented in the green commit --------------------- + + /// How long a BIG inscription stays valid before herregistratie is required. + // ponytail: fixed 5-year term — a calibration knob, not a config surface. If a demo needs it + // per-catalogus, promote it to policy passed in from the beheer config (S-15). + public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5); + + /// How long before the deadline the herregistratie reminder is sent (BIG: ~16 weeks). + // ponytail: fixed 16-week lead time — calibration knob; same promotion path as HerregistratieGeldigheid. + public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(16 * 7); + + /// When the registration was entered in the register, once approved; the start of its + /// herregistratie clock. Null until it is . + public DateTimeOffset? IngeschrevenOp { get; private set; } + + /// The date by which herregistratie must happen: inscription + validity. Null until + /// inscribed. + public DateTimeOffset? HerregistratieVoor => null; // RED stub + + /// Whether the herregistratie reminder has been sent for this inscription (S-17). + public bool HerregistratieReminderVerstuurd { get; private set; } + + /// Whether, as of , this registration is due a herregistratie + /// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not + /// already been reminded. + public bool HerregistratieReminderDue(DateTimeOffset asOf) => false; // RED stub + + /// Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a + /// no-op; only an inscribed registration can be reminded. + public void MarkHerregistratieReminderVerstuurd() { } // RED stub + /// /// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a /// submitted or in-behandeling registration to . Unlike diff --git a/services/domain/Big.Tests/ApproveRegistrationTests.cs b/services/domain/Big.Tests/ApproveRegistrationTests.cs index 3c7ae28..d756d44 100644 --- a/services/domain/Big.Tests/ApproveRegistrationTests.cs +++ b/services/domain/Big.Tests/ApproveRegistrationTests.cs @@ -19,7 +19,7 @@ public class ApproveRegistrationTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new ApproveRegistration(store, acl); + var handler = new ApproveRegistration(store, acl, TimeProvider.System); await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); @@ -36,7 +36,7 @@ public class ApproveRegistrationTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new ApproveRegistration(store, acl); + var handler = new ApproveRegistration(store, acl, TimeProvider.System); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, acl.ApproveCallCount); @@ -47,7 +47,7 @@ public class ApproveRegistrationTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new ApproveRegistration(store, acl); + var handler = new ApproveRegistration(store, acl, TimeProvider.System); var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); @@ -62,7 +62,7 @@ public class ApproveRegistrationTests var acl = new FakeAclClient(); var registration = Registration.Submit("123456782"); // no zaak yet store.Seed(registration); - var handler = new ApproveRegistration(store, acl); + var handler = new ApproveRegistration(store, acl, TimeProvider.System); var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); @@ -77,7 +77,7 @@ public class ApproveRegistrationTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new ApproveRegistration(store, acl); + var handler = new ApproveRegistration(store, acl, TimeProvider.System); await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); diff --git a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs index 9ad48de..26ac64e 100644 --- a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs +++ b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs @@ -29,7 +29,7 @@ public class BeoordeelRegistratieTests var registration = WithZaak(); store.Seed(registration); var tasks = TaskFor(registration); - var handler = new BeoordeelRegistratie(store, acl, tasks); + var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -50,7 +50,7 @@ public class BeoordeelRegistratieTests var registration = WithZaak(); store.Seed(registration); var tasks = TaskFor(registration); - var handler = new BeoordeelRegistratie(store, acl, tasks); + var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); @@ -69,7 +69,7 @@ public class BeoordeelRegistratieTests var registration = WithZaak(); registration.TakeIntoBehandeling(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -81,7 +81,7 @@ public class BeoordeelRegistratieTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([])); + var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, acl.ApproveCallCount); @@ -93,7 +93,7 @@ public class BeoordeelRegistratieTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([])); + var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System); var ex = await Assert.ThrowsAsync(() => handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren))); @@ -108,7 +108,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = Registration.Submit("123456782"); // no zaak yet store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System); var ex = await Assert.ThrowsAsync(() => handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren))); @@ -123,7 +123,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -139,7 +139,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); @@ -158,7 +158,7 @@ public class BeoordeelRegistratieTests var registration = WithZaak(); store.Seed(registration); var tasks = new FakeUserTaskClient([]); // no open task for this registration - var handler = new BeoordeelRegistratie(store, acl, tasks); + var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 14be96e..284da9f 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -79,7 +79,7 @@ public class InMemoryRegistrationStoreTests switch (transition) { case nameof(Registration.Withdraw): registration.Withdraw(); break; - case nameof(Registration.Approve): registration.Approve(); break; + case nameof(Registration.Approve): registration.Approve(DateTimeOffset.UtcNow); break; case nameof(Registration.Reject): registration.Reject(); break; case nameof(Registration.Expire): registration.Expire(); break; } diff --git a/services/domain/Big.Tests/RegistrationHerregistratieTests.cs b/services/domain/Big.Tests/RegistrationHerregistratieTests.cs new file mode 100644 index 0000000..7c1bc16 --- /dev/null +++ b/services/domain/Big.Tests/RegistrationHerregistratieTests.cs @@ -0,0 +1,90 @@ +using Big.Domain; + +namespace Big.Tests; + +// S-17 (#18): a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must +// herregistreren. The aggregate records when it was inscribed, derives the herregistratie deadline, and +// answers whether a reminder is due as of a given moment — the single rule the Quartz sweep and the +// store query both build on. All arithmetic is against an explicit "now" so it is wall-clock-free. +public class RegistrationHerregistratieTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero); + + // The moment the reminder window opens: inscribed exactly (geldigheid - herinneringstermijn) ago. + private static DateTimeOffset InscribedSoDueAt(DateTimeOffset asOf) + => asOf - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn; + + private static Registration Inscribed(DateTimeOffset ingeschrevenOp) + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(FakeAclClient.DefaultZaakUrl); + registration.Approve(ingeschrevenOp); + return registration; + } + + [Fact] + public void Approving_records_the_inscription_moment_and_the_herregistratie_deadline() + { + var registration = Inscribed(Now); + + Assert.Equal(Now, registration.IngeschrevenOp); + Assert.Equal(Now + Registration.HerregistratieGeldigheid, registration.HerregistratieVoor); + } + + [Fact] + public void A_reminder_is_due_the_moment_the_window_before_the_deadline_opens() + { + var registration = Inscribed(InscribedSoDueAt(Now)); + + Assert.True(registration.HerregistratieReminderDue(Now)); + } + + [Fact] + public void A_reminder_is_not_yet_due_one_day_before_the_window_opens() + { + var registration = Inscribed(InscribedSoDueAt(Now) + TimeSpan.FromDays(1)); + + Assert.False(registration.HerregistratieReminderDue(Now)); + } + + [Fact] + public void A_registration_that_is_not_ingeschreven_is_never_due_and_has_no_deadline() + { + var registration = Registration.Submit("123456782"); // INGEDIEND, never inscribed + + Assert.Null(registration.IngeschrevenOp); + Assert.Null(registration.HerregistratieVoor); + Assert.False(registration.HerregistratieReminderDue(Now)); + } + + [Fact] + public void A_reminded_registration_is_no_longer_due() + { + var registration = Inscribed(InscribedSoDueAt(Now)); + + registration.MarkHerregistratieReminderVerstuurd(); + + Assert.True(registration.HerregistratieReminderVerstuurd); + Assert.False(registration.HerregistratieReminderDue(Now)); + } + + [Fact] + public void Marking_the_reminder_sent_twice_is_idempotent() + { + var registration = Inscribed(InscribedSoDueAt(Now)); + + registration.MarkHerregistratieReminderVerstuurd(); + registration.MarkHerregistratieReminderVerstuurd(); + + Assert.True(registration.HerregistratieReminderVerstuurd); + } + + [Fact] + public void Marking_a_reminder_on_a_registration_that_is_not_ingeschreven_is_rejected() + { + var registration = Registration.Submit("123456782"); + + var ex = Assert.Throws(() => registration.MarkHerregistratieReminderVerstuurd()); + Assert.Contains("INGESCHREVEN", ex.Message); + } +} diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 003021e..98d134f 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -4,6 +4,9 @@ namespace Big.Tests; public class RegistrationTests { + // A fixed inscription moment for the approval tests; its exact value is irrelevant to them. + private static readonly DateTimeOffset Ingeschreven = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + [Fact] public void Submitting_a_registration_starts_in_ingediend() { @@ -103,7 +106,7 @@ public class RegistrationTests var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } @@ -113,7 +116,7 @@ public class RegistrationTests { var registration = Registration.Submit("123456782"); - var ex = Assert.Throws(() => registration.Approve()); + var ex = Assert.Throws(() => registration.Approve(Ingeschreven)); Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(RegistrationStatus.Ingediend, registration.Status); @@ -124,9 +127,9 @@ public class RegistrationTests { var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); - var ex = Assert.Throws(() => registration.Approve()); + var ex = Assert.Throws(() => registration.Approve(Ingeschreven)); Assert.Contains("only an INGEDIEND", ex.Message); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } @@ -157,7 +160,7 @@ public class RegistrationTests { var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); var ex = Assert.Throws(() => registration.TakeIntoBehandeling()); Assert.Contains("only an INGEDIEND", ex.Message); @@ -171,7 +174,7 @@ public class RegistrationTests registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.TakeIntoBehandeling(); - registration.Approve(); + registration.Approve(Ingeschreven); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } @@ -218,7 +221,7 @@ public class RegistrationTests var approveEx = Assert.Throws(() => { registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); }); Assert.Contains("IN_BEHANDELING", approveEx.Message); @@ -277,7 +280,7 @@ public class RegistrationTests { var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); var ex = Assert.Throws(() => registration.Withdraw()); Assert.Contains("only an INGEDIEND", ex.Message); @@ -336,7 +339,7 @@ public class RegistrationTests { var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - registration.Approve(); + registration.Approve(Ingeschreven); var ex = Assert.Throws(() => registration.Expire()); Assert.Contains("only an INGEDIEND", ex.Message); -- 2.54.0 From 22215865b321038d930f9b89f8899dbf863795f5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:51:47 +0200 Subject: [PATCH 2/9] feat(domain): record inscription date + herregistratie reminder-due rule (refs #18) Approve stores the inscription moment; HerregistratieVoor derives the deadline (inscription + 5-year validity); HerregistratieReminderDue(asOf) is true once the 16-week window before the deadline opens for a still-un-reminded inscription; MarkHerregistratieReminderVerstuurd is idempotent and guards against reminding a non-inscribed registration. refs #18 --- services/domain/Big.Domain/Registration.cs | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index 3aea662..ea19202 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -105,7 +105,7 @@ public sealed class Registration RequireOpenForDecision(nameof(Approve)); Status = RegistrationStatus.Ingeschreven; - // RED stub: the inscription moment is not stored yet. + IngeschrevenOp = ingeschrevenOp; } // --- Herregistratie (S-17) — RED stubs, implemented in the green commit --------------------- @@ -125,19 +125,35 @@ public sealed class Registration /// The date by which herregistratie must happen: inscription + validity. Null until /// inscribed. - public DateTimeOffset? HerregistratieVoor => null; // RED stub + public DateTimeOffset? HerregistratieVoor => + IngeschrevenOp is DateTimeOffset ingeschrevenOp ? ingeschrevenOp + HerregistratieGeldigheid : null; /// Whether the herregistratie reminder has been sent for this inscription (S-17). public bool HerregistratieReminderVerstuurd { get; private set; } /// Whether, as of , this registration is due a herregistratie /// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not - /// already been reminded. - public bool HerregistratieReminderDue(DateTimeOffset asOf) => false; // RED stub + /// already been reminded. Once inside the window it stays due until reminded (an overdue inscription + /// is still due). This is the single rule the store query and the sweep both build on. + public bool HerregistratieReminderDue(DateTimeOffset asOf) => + Status == RegistrationStatus.Ingeschreven + && !HerregistratieReminderVerstuurd + && IngeschrevenOp is DateTimeOffset ingeschrevenOp + && asOf >= ingeschrevenOp + HerregistratieGeldigheid - Herinneringstermijn; /// Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a - /// no-op; only an inscribed registration can be reminded. - public void MarkHerregistratieReminderVerstuurd() { } // RED stub + /// no-op (§8.6); only an inscribed registration can be reminded. + public void MarkHerregistratieReminderVerstuurd() + { + if (HerregistratieReminderVerstuurd) + return; + + if (Status != RegistrationStatus.Ingeschreven) + throw new InvalidOperationException( + $"Registration {Id} is {Status}; only an INGESCHREVEN registration can be sent a herregistratie reminder."); + + HerregistratieReminderVerstuurd = true; + } /// /// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a -- 2.54.0 From 3b3a44b17725bb58c510eb8ac8b36fd8e40f2f03 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:52:58 +0200 Subject: [PATCH 3/9] test(domain): store finds inscriptions due for a herregistratie reminder (refs #18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New IRegistrationStore.FindDueForHerregistratieReminderAsync — the sweep's candidate set. The production store is stubbed to an empty list so the new test fails; the green commit filters on the aggregate's own reminder-due rule. refs #18 --- services/domain/Big.Application/Ports.cs | 7 ++++++ .../InMemoryRegistrationStore.cs | 4 ++++ services/domain/Big.Tests/Fakes.cs | 5 ++++ .../InMemoryRegistrationStoreTests.cs | 23 +++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 08fdbde..70ec270 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -107,6 +107,13 @@ public interface IRegistrationStore /// registration, or null if they have none in flight. Lets the self-service portal resume /// an existing registration after a refresh (S-26); terminal registrations are not resumed. Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default); + + /// The inscriptions whose herregistratie reminder is due as of and + /// not yet sent — the herregistratie reminder sweep's candidate set (S-17). The predicate is the + /// aggregate's own rule, so the store never + /// duplicates the herregistratie policy. + Task> FindDueForHerregistratieReminderAsync( + DateTimeOffset asOf, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs index 086dc81..75e630c 100644 --- a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -26,4 +26,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) => Task.FromResult(_byId.Values.FirstOrDefault(r => r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); + + public Task> FindDueForHerregistratieReminderAsync( + DateTimeOffset asOf, CancellationToken ct = default) + => Task.FromResult>([]); // RED stub } diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index c9ff30f..623c331 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -26,6 +26,11 @@ internal sealed class FakeRegistrationStore : IRegistrationStore => Task.FromResult(_byId.Values.FirstOrDefault(r => r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); + public Task> FindDueForHerregistratieReminderAsync( + DateTimeOffset asOf, CancellationToken ct = default) + => Task.FromResult>( + _byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList()); + public void Seed(Registration registration) => _byId[registration.Id] = registration; } diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 284da9f..dc21fa7 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -96,4 +96,27 @@ public class InMemoryRegistrationStoreTests Assert.Null(await store.FindOpenByBsnAsync("123456782")); } + + [Fact] + public async Task Finds_only_the_inscriptions_due_for_a_herregistratie_reminder() + { + var now = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero); + var store = new InMemoryRegistrationStore(); + + var due = Registration.Submit("123456782"); + due.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + due.Approve(now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn); + await store.SaveAsync(due); + + var freshlyInscribed = Registration.Submit("111111110"); + freshlyInscribed.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/def")); + freshlyInscribed.Approve(now); + await store.SaveAsync(freshlyInscribed); + + await store.SaveAsync(Registration.Submit("222222222")); // still INGEDIEND — never inscribed + + var result = await store.FindDueForHerregistratieReminderAsync(now); + + Assert.Equal([due.Id], result.Select(r => r.Id).ToArray()); + } } -- 2.54.0 From 27fdde55516c1b31be69a3d5424f042792d72878 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:53:16 +0200 Subject: [PATCH 4/9] feat(domain): FindDueForHerregistratieReminderAsync filters on the reminder-due rule (refs #18) refs #18 --- .../domain/Big.Infrastructure/InMemoryRegistrationStore.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs index 75e630c..68a6590 100644 --- a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -29,5 +29,6 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task> FindDueForHerregistratieReminderAsync( DateTimeOffset asOf, CancellationToken ct = default) - => Task.FromResult>([]); // RED stub + => Task.FromResult>( + _byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList()); } -- 2.54.0 From 3c841c04bd3dd324b61e5aed2d21ed2f4f49ed2f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:54:38 +0200 Subject: [PATCH 5/9] test(domain): herregistratie reminder sweep flags + returns due inscriptions (refs #18) HerregistratieReminderSweep reminds every due inscription, persists the flag so a re-fire is a no-op, and returns the reminded ids. Stubbed to an empty result so the new tests fail; green implements the sweep. Adds a FixedClock TimeProvider fake so the sweep is deterministic without a testing package. refs #18 --- .../HerregistratieReminderSweep.cs | 18 +++++ services/domain/Big.Tests/Fakes.cs | 7 ++ .../HerregistratieReminderSweepTests.cs | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 services/domain/Big.Application/HerregistratieReminderSweep.cs create mode 100644 services/domain/Big.Tests/HerregistratieReminderSweepTests.cs diff --git a/services/domain/Big.Application/HerregistratieReminderSweep.cs b/services/domain/Big.Application/HerregistratieReminderSweep.cs new file mode 100644 index 0000000..ddc3d82 --- /dev/null +++ b/services/domain/Big.Application/HerregistratieReminderSweep.cs @@ -0,0 +1,18 @@ +using Big.Domain; + +namespace Big.Application; + +/// +/// The herregistratie reminder sweep (S-17): find the inscriptions whose herregistratie deadline is +/// within the reminder window and have not yet been reminded, mark each reminded, and persist it. Pure +/// application logic over ports — it knows nothing of Quartz; the scheduled job that fires it on a cron +/// lives in Infrastructure (mirroring how the pumps' processors are pure and the pump is the shell). +/// Idempotent: drops an inscription from +/// the next sweep's candidate set, so a re-fire reminds no one twice. Returns the reminded ids so the +/// caller can observe the sweep's effect — the reminder itself is the flag persisted on the aggregate. +/// +public sealed class HerregistratieReminderSweep(IRegistrationStore store, TimeProvider clock) +{ + public Task> SweepAsync(CancellationToken ct = default) + => Task.FromResult>([]); // RED stub +} diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index 623c331..f740b96 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -90,6 +90,13 @@ internal sealed class FakeUserTaskClient(IReadOnlyList open) : } } +/// A pinned to a fixed instant, so time-based use cases (the +/// herregistratie sweep, S-17) are deterministic without the TimeProvider.Testing package. +internal sealed class FixedClock(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => now; +} + /// A fake ACL client that records the bsn it was asked to open a zaak for and returns a /// fixed zaak URL. internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient diff --git a/services/domain/Big.Tests/HerregistratieReminderSweepTests.cs b/services/domain/Big.Tests/HerregistratieReminderSweepTests.cs new file mode 100644 index 0000000..e2d3857 --- /dev/null +++ b/services/domain/Big.Tests/HerregistratieReminderSweepTests.cs @@ -0,0 +1,67 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +// S-17 (#18): the sweep behind the Quartz job. It reminds every inscription whose herregistratie +// reminder is due, marks each so a re-fire is a no-op (§8.6), and returns the reminded ids. Pure over +// the store + an injected clock — no Quartz here. +public class HerregistratieReminderSweepTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero); + + private static Registration Inscribed(string bsn, DateTimeOffset ingeschrevenOp) + { + var registration = Registration.Submit(bsn); + registration.AttachZaak(FakeAclClient.DefaultZaakUrl); + registration.Approve(ingeschrevenOp); + return registration; + } + + // Inscribed exactly (geldigheid - herinneringstermijn) before Now: the reminder window is open. + private static Registration Due(string bsn) + => Inscribed(bsn, Now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn); + + [Fact] + public async Task Reminds_and_persists_every_due_inscription_and_returns_their_ids() + { + var store = new FakeRegistrationStore(); + var a = Due("123456782"); + var b = Due("111111110"); + var freshlyInscribed = Inscribed("222222222", Now); // not yet in the window + store.Seed(a); + store.Seed(b); + store.Seed(freshlyInscribed); + + var reminded = await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync(); + + Assert.Equal(new HashSet { a.Id, b.Id }, reminded.ToHashSet()); + Assert.True((await store.GetAsync(a.Id))!.HerregistratieReminderVerstuurd); + Assert.True((await store.GetAsync(b.Id))!.HerregistratieReminderVerstuurd); + Assert.False((await store.GetAsync(freshlyInscribed.Id))!.HerregistratieReminderVerstuurd); + Assert.Equal(2, store.SaveCount); + } + + [Fact] + public async Task A_second_sweep_reminds_no_one_again() + { + var store = new FakeRegistrationStore(); + store.Seed(Due("123456782")); + var sweep = new HerregistratieReminderSweep(store, new FixedClock(Now)); + + await sweep.SweepAsync(); + var second = await sweep.SweepAsync(); + + Assert.Empty(second); + Assert.Equal(1, store.SaveCount); // only the first sweep persisted anything + } + + [Fact] + public async Task Reminds_no_one_when_nothing_is_due() + { + var store = new FakeRegistrationStore(); + store.Seed(Inscribed("123456782", Now)); // freshly inscribed — deadline is 5 years off + + Assert.Empty(await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync()); + } +} -- 2.54.0 From 1e27819386e419cb5f29d551f656b37f7a3e1d65 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:55:08 +0200 Subject: [PATCH 6/9] feat(domain): HerregistratieReminderSweep reminds + persists due inscriptions (refs #18) refs #18 --- .../HerregistratieReminderSweep.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/services/domain/Big.Application/HerregistratieReminderSweep.cs b/services/domain/Big.Application/HerregistratieReminderSweep.cs index ddc3d82..626b8e8 100644 --- a/services/domain/Big.Application/HerregistratieReminderSweep.cs +++ b/services/domain/Big.Application/HerregistratieReminderSweep.cs @@ -13,6 +13,18 @@ namespace Big.Application; /// public sealed class HerregistratieReminderSweep(IRegistrationStore store, TimeProvider clock) { - public Task> SweepAsync(CancellationToken ct = default) - => Task.FromResult>([]); // RED stub + public async Task> SweepAsync(CancellationToken ct = default) + { + var due = await store.FindDueForHerregistratieReminderAsync(clock.GetUtcNow(), ct); + + var reminded = new List(due.Count); + foreach (var registration in due) + { + registration.MarkHerregistratieReminderVerstuurd(); + await store.SaveAsync(registration, ct); + reminded.Add(registration.Id); + } + + return reminded; + } } -- 2.54.0 From f49cad190081bf31c1402260393e7ebe3c8bc640 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 11:58:42 +0200 Subject: [PATCH 7/9] feat(domain): Quartz cron job fires the herregistratie sweep; expose deadline on read (refs #18) HerregistratieReminderJob (Quartz IJob) fires HerregistratieReminderSweep on a daily cron wired in Big.Api (overridable via Quartz__Cron), and logs how many reminders went out. Quartz.NET is used for this time-triggered fleet sweep, distinct from the queue-draining pumps (ADR-0022). GET /registrations/{id} now returns herregistratieVoor + herregistratieReminderVerstuurd. The scheduling shell is excluded from mutation, mirroring the pumps. refs #18 --- services/domain/Big.Api/Big.Api.csproj | 4 +++ services/domain/Big.Api/Program.cs | 25 ++++++++++++++++-- .../Big.Infrastructure.csproj | 1 + .../HerregistratieReminderJob.cs | 26 +++++++++++++++++++ services/domain/stryker-config.json | 3 ++- .../Steps/EenRegistratieBeoordelenSteps.cs | 2 +- .../acceptance/Support/InMemoryDomainPorts.cs | 5 ++++ 7 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 services/domain/Big.Infrastructure/HerregistratieReminderJob.cs diff --git a/services/domain/Big.Api/Big.Api.csproj b/services/domain/Big.Api/Big.Api.csproj index ae03b53..673337b 100644 --- a/services/domain/Big.Api/Big.Api.csproj +++ b/services/domain/Big.Api/Big.Api.csproj @@ -5,6 +5,10 @@ + + + + net10.0 enable diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 03b8a01..2a2827e 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -1,6 +1,7 @@ using Big.Application; using Big.Domain; using Big.Infrastructure; +using Quartz; var builder = WebApplication.CreateBuilder(args); @@ -40,6 +41,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. builder.Services.AddHostedService(); @@ -50,6 +52,19 @@ builder.Services.AddHostedService(); // parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017). builder.Services.AddHostedService(); +// The herregistratie reminder sweep runs on a daily cron via Quartz.NET (S-17, ADR-0022) — a +// time-triggered fleet sweep, deliberately a different mechanism from the queue-draining pumps above. +// The cron is overridable with Quartz__Cron; it defaults to 03:00 daily. +builder.Services.AddQuartz(q => +{ + var jobKey = new JobKey("herregistratie-reminder"); + q.AddJob(jobKey); + q.AddTrigger(t => t + .ForJob(jobKey) + .WithCronSchedule(builder.Configuration["Quartz:Cron"] ?? "0 0 3 * * ?")); +}); +builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true); + var app = builder.Build(); app.MapGet("/health", () => "Healthy"); @@ -169,7 +184,8 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca return registration is null ? Results.NotFound() : Results.Ok(new RegistrationResponse( - registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString())); + registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString(), + registration.HerregistratieVoor?.ToString("O"), registration.HerregistratieReminderVerstuurd)); }); await app.RunAsync(); @@ -182,6 +198,11 @@ public sealed record WithdrawRequest(string Bsn); public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null); -public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); +public sealed record RegistrationResponse( + string RegistrationId, + string Status, + string? ZaakUrl, + string? HerregistratieVoor = null, + bool HerregistratieReminderVerstuurd = false); public partial class Program; diff --git a/services/domain/Big.Infrastructure/Big.Infrastructure.csproj b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj index 6a23067..379c04e 100644 --- a/services/domain/Big.Infrastructure/Big.Infrastructure.csproj +++ b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj @@ -19,6 +19,7 @@ + diff --git a/services/domain/Big.Infrastructure/HerregistratieReminderJob.cs b/services/domain/Big.Infrastructure/HerregistratieReminderJob.cs new file mode 100644 index 0000000..55933b6 --- /dev/null +++ b/services/domain/Big.Infrastructure/HerregistratieReminderJob.cs @@ -0,0 +1,26 @@ +using Big.Application; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Big.Infrastructure; + +/// +/// The Quartz job that fires the herregistratie reminder sweep on a cron schedule (S-17, ADR-0022). +/// A deliberately thin shell — it resolves the pure (Quartz's +/// MS-DI job factory gives each fire its own scope) and logs how many reminders went out; all the +/// sweep logic is unit-tested in the application layer. Quartz drives this — rather than a +/// BackgroundService poll loop like the pumps — because it is a time-triggered fleet sweep, not a +/// queue to drain (the distinction recorded in ADR-0022). +/// stops a slow sweep overlapping the next fire against the shared store. +/// +[DisallowConcurrentExecution] +public sealed class HerregistratieReminderJob( + HerregistratieReminderSweep sweep, ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var reminded = await sweep.SweepAsync(context.CancellationToken); + logger.LogInformation( + "Herregistratie-sweep voltooid: {Count} herinnering(en) verstuurd.", reminded.Count); + } +} diff --git a/services/domain/stryker-config.json b/services/domain/stryker-config.json index ac22608..3b51fae 100644 --- a/services/domain/stryker-config.json +++ b/services/domain/stryker-config.json @@ -6,7 +6,8 @@ "mutate": [ "!**/OpenZaakJobPump.cs", "!**/BeoordelingEscalatiePump.cs", - "!**/RegistratieVerlopenPump.cs" + "!**/RegistratieVerlopenPump.cs", + "!**/HerregistratieReminderJob.cs" ], "thresholds": { "high": 95, diff --git a/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs index 39bfa3b..4c4dd6d 100644 --- a/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs +++ b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs @@ -40,7 +40,7 @@ public sealed class EenRegistratieBeoordelenSteps [When("the behandelaar decides \"(.*)\"")] public async Task WhenTheBehandelaarDecides(string besluit) - => await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync( + => await new BeoordeelRegistratie(_store, _acl, _tasks, TimeProvider.System).HandleAsync( new BeoordeelRegistratieCommand(_id, Enum.Parse(besluit, ignoreCase: true))); [Then("the registration has status \"(.*)\"")] diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 72165ed..5475964 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -221,4 +221,9 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) => Task.FromResult(_byId.Values.FirstOrDefault(r => r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); + + public Task> FindDueForHerregistratieReminderAsync( + DateTimeOffset asOf, CancellationToken ct = default) + => Task.FromResult>( + _byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList()); } -- 2.54.0 From b5085dc97800b3e7825f3f9dcd1351b965fa1656 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 12:01:36 +0200 Subject: [PATCH 8/9] refactor(domain): herregistratie reminder lead time to 90 days per S-17 spec (refs #18) refs #18 --- services/domain/Big.Domain/Registration.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index ea19202..89f5994 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -115,9 +115,9 @@ public sealed class Registration // per-catalogus, promote it to policy passed in from the beheer config (S-15). public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5); - /// How long before the deadline the herregistratie reminder is sent (BIG: ~16 weeks). - // ponytail: fixed 16-week lead time — calibration knob; same promotion path as HerregistratieGeldigheid. - public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(16 * 7); + /// How long before the deadline the herregistratie reminder is sent (S-17: 90 days). + // ponytail: fixed 90-day lead time — calibration knob; same promotion path as HerregistratieGeldigheid. + public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(90); /// When the registration was entered in the register, once approved; the start of its /// herregistratie clock. Null until it is . -- 2.54.0 From ccb268bdd526b162dca0904206ac6ca201c25f2a Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 12:01:36 +0200 Subject: [PATCH 9/9] docs: ADR-0022 + demo note + backlog sync for the herregistratie sweep (refs #18) ADR-0022 records using Quartz.NET for time-triggered fleet sweeps (pumps stay as queue-drainers); demo-script and BACKLOG describe S-17's outcome. refs #18 --- BACKLOG.md | 4 +- .../architecture/adr-0022-quartz-scheduler.md | 79 +++++++++++++++++++ docs/demo-script.md | 29 +++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr-0022-quartz-scheduler.md diff --git a/BACKLOG.md b/BACKLOG.md index 29b80d5..5857963 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -257,9 +257,9 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the **Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals. -### S-17 · Quartz.NET scheduler — herregistratie reminder sweep +### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅ -**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.) +**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers. --- diff --git a/docs/architecture/adr-0022-quartz-scheduler.md b/docs/architecture/adr-0022-quartz-scheduler.md new file mode 100644 index 0000000..43c5248 --- /dev/null +++ b/docs/architecture/adr-0022-quartz-scheduler.md @@ -0,0 +1,79 @@ +# ADR-0022: Quartz.NET for time-triggered fleet sweeps + +- **Status:** Accepted +- **Date:** 2026-07-23 +- **Deciders:** Respellion engineering +- **Slice:** S-17 (#18) · **Proposal issue:** #120 + +## Context + +A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional +must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day, +scan the register for inscriptions whose deadline is within the reminder window and +remind each one. + +The Domain Service already runs periodic background work — `OpenZaakJobPump`, +`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job +pollers**: they drain Flowable's external-task/job queues at-least-once, picking up +work as soon as it is parked, on a short poll interval. The reminder sweep is a +different shape of work: **time-triggered**, once a day, over our own store — there +is no queue to drain and no "as soon as possible" requirement. + +The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide +sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new +dependency, so this decision is recorded before the code lands (CLAUDE.md §14). + +## Decision + +**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie +reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.** + +- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily + at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it + resolves the pure `HerregistratieReminderSweep` (application layer) and logs how + many reminders went out. +- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`, + which the store query and the sweep both build on. The sweep marks each reminded + inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice + (§8.6). + +Two options were rejected: + +1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it + drifts to process-start time, has no cron/misfire semantics, and contradicts the + PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is + for. +2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the + pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue + continuously" work is churn and a boundary change for negative benefit. The + teachable distinction is worth keeping: **pumps drain queues; Quartz fires + sweeps.** + +## Consequences + +**Positive** + +- Cron scheduling with restart-stable timing and misfire handling, for free. +- The reminder rule is one domain method, reused by the store query and the sweep; + the scheduler owns none of the policy. +- The reference app now demonstrates the intended Scheduler component. + +**Negative / costs** + +- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service. +- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model + two genuinely different concerns, documented here. + +**Follow-up** + +- The validity term (5 years) and reminder lead time (16 weeks) are domain + calibration knobs; promote them to beheer config (S-15) if a demo needs them + per-catalogus. +- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered + store is a later concern if the Domain Service is scaled out. + +## Coupling rules touched (CLAUDE.md §8) + +None. Quartz is internal to the Domain Service and drives an application use case +over the store port. No ZGW or Flowable coupling is added; the sweep talks to no +peer module. diff --git a/docs/demo-script.md b/docs/demo-script.md index c94a4e0..eeebfbb 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,35 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022) + +**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from +which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job +in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription +inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged. +The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism +from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues, +ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a +log line. + +```bash +# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep): +cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \ + --filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep" +# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped +# on the next sweep; the sweep flags + persists every due inscription and returns their ids. + +# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on: +curl -s localhost:8000/registrations/ | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}' +# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept. +``` + +**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob` +→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by +the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log. + +--- + ## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020) **Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG -- 2.54.0