feat: extend resident permit service journey
Dependency Audit / dependency-audit (push) Successful in 1m46s
Deployment Installer / deployment-installer (push) Successful in 9s
Security Audit / security-audit (push) Failing after 1s

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent 69519a92b4
commit f8b06887d2
6 changed files with 252 additions and 3 deletions
@@ -48,6 +48,23 @@ Decision, Postbox delivery, and Records target. Changing this flagship scenario
is a product decision; implementations may add further scenarios without is a product decision; implementations may add further scenarios without
weakening or silently replacing its acceptance gates. weakening or silently replacing its acceptance gates.
The reference fixes an email-link applicant-status profile. The exact
published Form revision names the linked email field and bounded expiry/request
limits. Submission issues a tracking grant, a matching request delegates mail
delivery to Notifications using a hash-only short-lived secret, and Portal
presents only the public lifecycle projection. Forms Runtime's module tests
also cover authenticated-only and permanent-link variants; the flagship keeps
email-link mode because it exercises identity minimization, delivery,
revocation, resend, expiry, and non-enumerating failure behavior in one slice.
The Case-to-payment handoff now has an executable first contract as well. The
flagship requests a fixed EUR obligation through `payments.requests`, retains
the Case and Workflow context references, proves exact replay, and reconciles a
full offline receipt against a Files-owned immutable evidence reference. This
does not simulate online checkout or accounting: provider callbacks, partial
payments, corrections, refunds, Ledger posting, and XRechnung remain separate
governed slices.
The Records vertical now supplies the journey's native file plan, immutable The Records vertical now supplies the journey's native file plan, immutable
record and item revisions, chronology, close/reopen, retention calculation, record and item revisions, chronology, close/reopen, retention calculation,
holds, appraisal, independent disposition approval, recovery-ledger evidence, holds, appraisal, independent disposition approval, recovery-ledger evidence,
+1
View File
@@ -57,6 +57,7 @@ full = [
"govoplan-mandates==0.1.18", "govoplan-mandates==0.1.18",
"govoplan-notifications==0.1.18", "govoplan-notifications==0.1.18",
"govoplan-parties==0.1.18", "govoplan-parties==0.1.18",
"govoplan-payments==0.1.19",
"govoplan-permits==0.1.18", "govoplan-permits==0.1.18",
"govoplan-poll==0.1.18", "govoplan-poll==0.1.18",
"govoplan-portal==0.1.18", "govoplan-portal==0.1.18",
+1
View File
@@ -30,6 +30,7 @@
-e ../govoplan-parties -e ../govoplan-parties
-e ../govoplan-mandates -e ../govoplan-mandates
-e ../govoplan-decisions -e ../govoplan-decisions
-e ../govoplan-payments
-e ../govoplan-connectors -e ../govoplan-connectors
-e ../govoplan-datasources -e ../govoplan-datasources
-e ../govoplan-dataflow -e ../govoplan-dataflow
+18
View File
@@ -21,10 +21,17 @@
"version": "3", "version": "3",
"fields": { "fields": {
"applicant_name": "Ada Lovelace", "applicant_name": "Ada Lovelace",
"applicant_email": "ada.lovelace@example.test",
"residence_address": "Musterstrasse 17, 10115 Berlin", "residence_address": "Musterstrasse 17, 10115 Berlin",
"licence_plate": "B-AL 1843" "licence_plate": "B-AL 1843"
} }
}, },
"status_access": {
"mode": "email_link",
"email_field_key": "applicant_email",
"token_ttl_seconds": 1800,
"request_limit_per_hour": 3
},
"assisted_intake": { "assisted_intake": {
"channel": "counter", "channel": "counter",
"affected_party_ref": "party:resident-ada-lovelace", "affected_party_ref": "party:resident-ada-lovelace",
@@ -59,6 +66,14 @@
"delivery_channel": "postbox", "delivery_channel": "postbox",
"remedy": "review:administrative-court" "remedy": "review:administrative-court"
}, },
"payment": {
"mode": "manual",
"amount_minor": 3000,
"currency": "EUR",
"subject": "Resident parking permit fee",
"due_days": 14,
"evidence_owner": "files"
},
"records": { "records": {
"file_plan_key": "traffic.resident-parking-permits", "file_plan_key": "traffic.resident-parking-permits",
"retention_policy_ref": "records:resident-parking-permit" "retention_policy_ref": "records:resident-parking-permit"
@@ -67,14 +82,17 @@
"automated": [ "automated": [
"The published service and exact form revision drive digital intake.", "The published service and exact form revision drive digital intake.",
"An authenticated assisted session uses the same exact form and validation rules while retaining purpose, authority, channel, party, accessibility, source, correction, and read-back provenance.", "An authenticated assisted session uses the same exact form and validation rules while retaining purpose, authority, channel, party, accessibility, source, correction, and read-back provenance.",
"The configured applicant email issues a short-lived, hash-only status link through Notifications and exposes only the bounded status timeline.",
"An idempotent replay returns the same persisted submission.", "An idempotent replay returns the same persisted submission.",
"The human review handoff survives a database-session restart and remains visible in Tasks until completion.", "The human review handoff survives a database-session restart and remains visible in Tasks until completion.",
"The formal decision retains party, mandate, legal-basis, evidence, delivery, review, and exact revision references.", "The formal decision retains party, mandate, legal-basis, evidence, delivery, review, and exact revision references.",
"The Case-bound payment handoff creates a replay-safe obligation and accepts a full manual receipt only with exact amount, currency, transaction reference, and immutable evidence.",
"Forms Runtime, Cases, and Decisions can expose exact snapshots for explicit eAkte filing." "Forms Runtime, Cases, and Decisions can expose exact snapshots for explicit eAkte filing."
], ],
"manual_or_target": [ "manual_or_target": [
"Complete the digital journey with keyboard and screen reader at desktop and mobile widths.", "Complete the digital journey with keyboard and screen reader at desktop and mobile widths.",
"Complete the assisted operator journey with keyboard and screen reader at desktop and mobile widths.", "Complete the assisted operator journey with keyboard and screen reader at desktop and mobile widths.",
"Open, resend, expire, and revoke the applicant status link with keyboard and screen reader at desktop and mobile widths.",
"Verify the configured Postbox or external delivery provider, including unknown outcome and reconciliation.", "Verify the configured Postbox or external delivery provider, including unknown outcome and reconciliation.",
"Restore the pinned composition and reconstruct the exact form, case, decision, delivery evidence, and eAkte chronology.", "Restore the pinned composition and reconstruct the exact form, case, decision, delivery evidence, and eAkte chronology.",
"Transfer through a named archive profile and retain independently signed target evidence." "Transfer through a named archive profile and retain independently signed target evidence."
+183 -3
View File
@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import unittest import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -15,6 +16,7 @@ from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.institutional import ( from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS, CAPABILITY_FORM_DEFINITIONS,
CAPABILITY_SERVICE_DEFINITIONS, CAPABILITY_SERVICE_DEFINITIONS,
EvidenceReference,
FormDefinition, FormDefinition,
FormFieldDefinition, FormFieldDefinition,
InstitutionalReference, InstitutionalReference,
@@ -23,6 +25,11 @@ from govoplan_core.core.institutional import (
TemporalRevision, TemporalRevision,
service_launch_capability, service_launch_capability,
) )
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.payments import (
ManualPaymentReconciliationCommand,
PaymentRequestCommand,
)
from govoplan_core.core.runtime_coordination import ( from govoplan_core.core.runtime_coordination import (
DistributedLease, DistributedLease,
RuntimeIdentity, RuntimeIdentity,
@@ -50,6 +57,9 @@ from govoplan_forms_runtime.backend.db.models import (
FormInstanceRevision, FormInstanceRevision,
FormIntakeProfile, FormIntakeProfile,
FormIntakeSession, FormIntakeSession,
FormStatusAccessGrant,
FormStatusAccessPolicy,
FormStatusAccessToken,
) )
from govoplan_forms_runtime.backend.intake import FormIntakeService from govoplan_forms_runtime.backend.intake import FormIntakeService
from govoplan_forms_runtime.backend.service import ( from govoplan_forms_runtime.backend.service import (
@@ -57,7 +67,14 @@ from govoplan_forms_runtime.backend.service import (
FormRuntimeService, FormRuntimeService,
FormsServiceLauncher, FormsServiceLauncher,
) )
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
from govoplan_portal.backend.service_directory import PortalServiceDirectory from govoplan_portal.backend.service_directory import PortalServiceDirectory
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
from govoplan_payments.backend.service import SqlPaymentRequestProvider
from govoplan_tasks.backend.aggregation import aggregate_work_items from govoplan_tasks.backend.aggregation import aggregate_work_items
from govoplan_workflow_engine.backend.db.models import ( from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition, WorkflowDefinition,
@@ -173,9 +190,29 @@ class _FormRegistry(_Registry):
self.capabilities[service_launch_capability("form")] = FormsServiceLauncher( self.capabilities[service_launch_capability("form")] = FormsServiceLauncher(
self self
) )
self.notifications = _NotificationProvider()
self.capabilities[CAPABILITY_NOTIFICATIONS_DISPATCH] = self.notifications
def has(self, module_id: str) -> bool: def has(self, module_id: str) -> bool:
return module_id in {"portal", "forms", "forms_runtime"} return module_id in {"portal", "forms", "forms_runtime", "notifications"}
class _NotificationProvider:
def __init__(self) -> None:
self.requests: list[object] = []
def tenant_id_for_notification(self, session, *, notification_id):
return "tenant-1"
def enqueue_notification(self, session, request, *, enqueue_delivery=True):
self.requests.append(request)
return {"id": f"notification-{len(self.requests)}"}
def deliver_notification(self, session, *, notification_id):
return {"id": notification_id}
def deliver_pending(self, session, *, tenant_id=None, limit=50):
return {"delivered": 0}
class _WorkflowTaskRegistry: class _WorkflowTaskRegistry:
@@ -248,8 +285,10 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None: def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None:
self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"]) self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"])
self.assertEqual("de-DE", JOURNEY["locale"]) self.assertEqual("de-DE", JOURNEY["locale"])
self.assertEqual(6, len(JOURNEY["acceptance"]["automated"])) self.assertEqual("email_link", JOURNEY["status_access"]["mode"])
self.assertEqual(5, len(JOURNEY["acceptance"]["manual_or_target"])) self.assertEqual("manual", JOURNEY["payment"]["mode"])
self.assertEqual(8, len(JOURNEY["acceptance"]["automated"]))
self.assertEqual(6, len(JOURNEY["acceptance"]["manual_or_target"]))
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None: def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:") engine = create_engine("sqlite+pysqlite:///:memory:")
@@ -290,6 +329,13 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
required=True, required=True,
constraints={"min_length": 2}, constraints={"min_length": 2},
), ),
FormFieldDefinition(
key="applicant_email",
label="Applicant email",
value_type="email",
required=True,
constraints={"min_length": 5},
),
FormFieldDefinition( FormFieldDefinition(
key="residence_address", key="residence_address",
label="Primary residence address", label="Primary residence address",
@@ -395,6 +441,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
FormIntakeProfile.__table__, FormIntakeProfile.__table__,
FormIntakeSession.__table__, FormIntakeSession.__table__,
FormAssistedConfirmation.__table__, FormAssistedConfirmation.__table__,
FormStatusAccessPolicy.__table__,
FormStatusAccessGrant.__table__,
FormStatusAccessToken.__table__,
): ):
table.create(engine) table.create(engine)
sessions = sessionmaker(bind=engine) sessions = sessionmaker(bind=engine)
@@ -424,6 +473,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
FormFieldDefinition( FormFieldDefinition(
key=key, key=key,
label=key.replace("_", " ").title(), label=key.replace("_", " ").title(),
value_type=(
"email" if key == "applicant_email" else "text"
),
required=True, required=True,
constraints={"min_length": 2}, constraints={"min_length": 2},
) )
@@ -435,6 +487,20 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
), ),
) )
registry = _FormRegistry(_service()) registry = _FormRegistry(_service())
status_access = JOURNEY["status_access"]
FormStatusAccessService(registry).upsert_policy(
session,
principal,
definition_ref=form.reference,
mode=status_access["mode"],
enabled=True,
email_field_key=status_access["email_field_key"],
token_ttl_seconds=status_access["token_ttl_seconds"],
request_limit_per_hour=status_access[
"request_limit_per_hour"
],
recorded_at=NOW,
)
intake = FormIntakeService(registry) intake = FormIntakeService(registry)
profile = intake.create_profile( profile = intake.create_profile(
session, session,
@@ -536,6 +602,50 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
self.assertEqual("submitted", submitted.status) self.assertEqual("submitted", submitted.status)
self.assertEqual(current.revision, confirmation.instance_revision) self.assertEqual(current.revision, confirmation.instance_revision)
self.assertEqual(assisted["affected_party_ref"], confirmation.confirmed_by_ref) self.assertEqual(assisted["affected_party_ref"], confirmation.confirmed_by_ref)
status_service = FormStatusAccessService(registry)
access = status_service.access_summary_for_instance(
resumed,
tenant_id="tenant-1",
instance_id=instance_id,
)
self.assertIsNotNone(access)
tracking_id = str(access["tracking_id"])
challenge = status_service.public_access_challenge(
resumed,
tracking_id=tracking_id,
)
self.assertEqual("email_link", challenge["mode"])
self.assertFalse(
status_service.request_email_link(
resumed,
tracking_id=tracking_id,
email="wrong@example.test",
requested_at=NOW + timedelta(minutes=5),
)
)
self.assertTrue(
status_service.request_email_link(
resumed,
tracking_id=tracking_id,
email=JOURNEY["form"]["fields"]["applicant_email"],
requested_at=NOW + timedelta(minutes=6),
)
)
notification = registry.notifications.requests[-1]
query = parse_qs(urlparse(notification.action_url).query)
projection = status_service.get_public_projection(
resumed,
tracking_id=tracking_id,
token=query["token"][0],
observed_at=NOW + timedelta(minutes=7),
)
self.assertEqual("submitted", projection["status"])
self.assertEqual(JOURNEY["title"], projection["title"])
self.assertEqual(
["submitted"],
[item["status"] for item in projection["timeline"]],
)
self.assertNotIn("values", projection)
finally: finally:
engine.dispose() engine.dispose()
@@ -682,6 +792,76 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
bind_process_runtime_identity(None) bind_process_runtime_identity(None)
engine.dispose() engine.dispose()
def test_case_bound_payment_handoff_is_replay_safe_and_evidence_bound(
self,
) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
PaymentObligation.__table__,
PaymentReconciliation.__table__,
PaymentEvent.__table__,
):
table.create(engine)
session = Session(engine)
provider = SqlPaymentRequestProvider()
payment = JOURNEY["payment"]
try:
command = PaymentRequestCommand(
tenant_id="tenant-1",
source_module="cases",
source_resource_type="case",
source_resource_id="case-1",
amount_minor=payment["amount_minor"],
currency=payment["currency"],
subject=payment["subject"],
idempotency_key="resident-permit-case-1-fee",
requested_at=NOW + timedelta(days=1),
requested_by_ref="workflow:resident-parking-permit-review",
due_at=NOW + timedelta(days=1 + payment["due_days"]),
context_refs={
"case": "case-1",
"workflow": "workflow:resident-parking-permit-review",
},
)
requested = provider.request_payment(session, command)
replay = provider.request_payment(session, command)
self.assertEqual(requested["payment_id"], replay["payment_id"])
self.assertTrue(replay["replayed"])
self.assertEqual("case-1", requested["source"]["resource_id"])
paid = provider.reconcile_manual_payment(
session,
ManualPaymentReconciliationCommand(
tenant_id="tenant-1",
payment_id=str(requested["payment_id"]),
amount_minor=payment["amount_minor"],
currency=payment["currency"],
transaction_reference="BANK-RPP-2026-0001",
evidence_ref=EvidenceReference(
kind="document",
owner_module=payment["evidence_owner"],
evidence_id="file-payment-rpp-1",
tenant_id="tenant-1",
version="1",
checksum="b" * 64,
),
idempotency_key="resident-permit-bank-receipt-1",
received_at=NOW + timedelta(days=2),
recorded_at=NOW + timedelta(days=2, minutes=5),
recorded_by_ref="account:payment-officer-1",
),
)
session.commit()
self.assertEqual("paid", paid["status"])
self.assertEqual(
"BANK-RPP-2026-0001",
paid["reconciliation"]["transaction_reference"],
)
self.assertEqual(2, len(paid["events"]))
finally:
session.close()
engine.dispose()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -1146,6 +1146,38 @@
"rationale": "This capability-first module intentionally exposes a headless API for other modules and integrations.", "rationale": "This capability-first module intentionally exposes a headless API for other modules and integrations.",
"repository": "govoplan-parties" "repository": "govoplan-parties"
}, },
{
"category": "missing_ui",
"method": "GET",
"path": "/payments/requests",
"rationale": "The first payment vertical slice is capability-first; its guided operator workspace will adopt the centralized layout usage contract.",
"repository": "govoplan-payments",
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-payments/issues/1"
},
{
"category": "missing_ui",
"method": "POST",
"path": "/payments/requests",
"rationale": "The first payment vertical slice is capability-first; its guided operator workspace will adopt the centralized layout usage contract.",
"repository": "govoplan-payments",
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-payments/issues/1"
},
{
"category": "missing_ui",
"method": "GET",
"path": "/payments/requests/{}",
"rationale": "The first payment vertical slice is capability-first; its guided operator workspace will adopt the centralized layout usage contract.",
"repository": "govoplan-payments",
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-payments/issues/1"
},
{
"category": "missing_ui",
"method": "POST",
"path": "/payments/requests/{}/manual-reconciliations",
"rationale": "The first payment vertical slice is capability-first; its guided operator workspace will adopt the centralized layout usage contract.",
"repository": "govoplan-payments",
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-payments/issues/1"
},
{ {
"category": "intentionally_headless", "category": "intentionally_headless",
"method": "DELETE", "method": "DELETE",