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
+18
View File
@@ -21,10 +21,17 @@
"version": "3",
"fields": {
"applicant_name": "Ada Lovelace",
"applicant_email": "ada.lovelace@example.test",
"residence_address": "Musterstrasse 17, 10115 Berlin",
"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": {
"channel": "counter",
"affected_party_ref": "party:resident-ada-lovelace",
@@ -59,6 +66,14 @@
"delivery_channel": "postbox",
"remedy": "review:administrative-court"
},
"payment": {
"mode": "manual",
"amount_minor": 3000,
"currency": "EUR",
"subject": "Resident parking permit fee",
"due_days": 14,
"evidence_owner": "files"
},
"records": {
"file_plan_key": "traffic.resident-parking-permits",
"retention_policy_ref": "records:resident-parking-permit"
@@ -67,14 +82,17 @@
"automated": [
"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.",
"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.",
"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 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."
],
"manual_or_target": [
"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.",
"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.",
"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."
+183 -3
View File
@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import unittest
from sqlalchemy import create_engine
@@ -15,6 +16,7 @@ from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
CAPABILITY_SERVICE_DEFINITIONS,
EvidenceReference,
FormDefinition,
FormFieldDefinition,
InstitutionalReference,
@@ -23,6 +25,11 @@ from govoplan_core.core.institutional import (
TemporalRevision,
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 (
DistributedLease,
RuntimeIdentity,
@@ -50,6 +57,9 @@ from govoplan_forms_runtime.backend.db.models import (
FormInstanceRevision,
FormIntakeProfile,
FormIntakeSession,
FormStatusAccessGrant,
FormStatusAccessPolicy,
FormStatusAccessToken,
)
from govoplan_forms_runtime.backend.intake import FormIntakeService
from govoplan_forms_runtime.backend.service import (
@@ -57,7 +67,14 @@ from govoplan_forms_runtime.backend.service import (
FormRuntimeService,
FormsServiceLauncher,
)
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
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_workflow_engine.backend.db.models import (
WorkflowDefinition,
@@ -173,9 +190,29 @@ class _FormRegistry(_Registry):
self.capabilities[service_launch_capability("form")] = FormsServiceLauncher(
self
)
self.notifications = _NotificationProvider()
self.capabilities[CAPABILITY_NOTIFICATIONS_DISPATCH] = self.notifications
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:
@@ -248,8 +285,10 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None:
self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"])
self.assertEqual("de-DE", JOURNEY["locale"])
self.assertEqual(6, len(JOURNEY["acceptance"]["automated"]))
self.assertEqual(5, len(JOURNEY["acceptance"]["manual_or_target"]))
self.assertEqual("email_link", JOURNEY["status_access"]["mode"])
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:
engine = create_engine("sqlite+pysqlite:///:memory:")
@@ -290,6 +329,13 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
required=True,
constraints={"min_length": 2},
),
FormFieldDefinition(
key="applicant_email",
label="Applicant email",
value_type="email",
required=True,
constraints={"min_length": 5},
),
FormFieldDefinition(
key="residence_address",
label="Primary residence address",
@@ -395,6 +441,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
FormIntakeProfile.__table__,
FormIntakeSession.__table__,
FormAssistedConfirmation.__table__,
FormStatusAccessPolicy.__table__,
FormStatusAccessGrant.__table__,
FormStatusAccessToken.__table__,
):
table.create(engine)
sessions = sessionmaker(bind=engine)
@@ -424,6 +473,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
FormFieldDefinition(
key=key,
label=key.replace("_", " ").title(),
value_type=(
"email" if key == "applicant_email" else "text"
),
required=True,
constraints={"min_length": 2},
)
@@ -435,6 +487,20 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
),
)
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)
profile = intake.create_profile(
session,
@@ -536,6 +602,50 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
self.assertEqual("submitted", submitted.status)
self.assertEqual(current.revision, confirmation.instance_revision)
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:
engine.dispose()
@@ -682,6 +792,76 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
bind_process_runtime_identity(None)
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__":
unittest.main()