9 Commits
Author SHA1 Message Date
zemion 69396fc0d0 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:58 +02:00
zemion f6b4834588 test: compare manifest version with package metadata
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:16:54 +02:00
zemion a081f59ab7 fix(packaging): expose immutable WebUI Git package for v0.1.22 2026-09-08 02:06:10 +02:00
zemion 2340bf53f9 docs(payments): add complete German payment guidance
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 01:54:19 +02:00
zemion e08bc8b992 feat(payments): add governed DSAR coverage 2026-08-21 04:26:14 +02:00
zemion da0ee0f325 refactor(webui): adopt semantic page actions 2026-08-19 18:47:45 +02:00
zemion 884d068689 Adopt semantic payments action layout 2026-08-19 14:26:26 +02:00
zemion a2dcd8f2dd Add guided Payments operator workspace
Module Package Release / publish-packages (push) Successful in 14s
2026-08-19 13:17:13 +02:00
zemion 630a7d39f7 feat: add governed payment request slice
Module Package Release / publish-packages (push) Successful in 13s
2026-08-19 12:33:34 +02:00
31 changed files with 4462 additions and 1 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+48 -1
View File
@@ -1,5 +1,52 @@
# govoplan-payments # GovOPlaN Payments
<!-- govoplan-repository-type:start --> <!-- govoplan-repository-type:start -->
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-payments` owns replay-safe payment obligations, human payment
references, append-only lifecycle evidence, and settlement reconciliation.
The first vertical slice implements full manual/offline payment receipt. A
Case, Workflow, or other procedure calls the Core `payments.requests`
capability, retains the returned payment ID, and later supplies the exact
amount/currency, external transaction reference, and a same-tenant versioned or
checksum-bound evidence reference. Payments rejects changed replays, partial or
cross-currency matches, cross-tenant evidence, and a second settlement.
Version 0.1.20 adds the permission-aware Payments operator workspace at
`/payments`. It uses the shared Core page, action, form, dialog, and table
grammar, keeps Reload and Create in stable collection slots, and guides request
creation and exact evidence-bound manual reconciliation without exposing raw
JSON.
Online payment providers, applicant checkout, partial payments, refunds,
reversals, Ledger posting, and XRechnung are intentionally separate next
slices. See [docs/PAYMENTS_DOMAIN.md](docs/PAYMENTS_DOMAIN.md).
Focused verification:
```sh
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run test:interface-pattern
```
## Git-source WebUI package
The repository root exposes `@govoplan/payments-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/payments-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+90
View File
@@ -0,0 +1,90 @@
# Payments Domain Boundary
## Purpose
Payments records payment obligations and authoritative settlement evidence for
GovOPlaN procedures. The first vertical slice is deliberately narrow: a Case,
Workflow, or other owner requests a fixed amount, and an authorized operator
records a full offline/manual receipt against immutable evidence.
## Ownership and contract
Payments owns:
- the tenant-bound payment ID and human payment reference;
- requested amount, ISO currency, subject, due time, and lifecycle state;
- replay-safe request and reconciliation semantics;
- the external transaction reference and typed evidence reference; and
- append-only request and reconciliation events.
Procedure modules call Core's `payments.requests` capability. They provide
their own stable source resource and optional Case/Workflow context references,
then retain the returned payment ID. They never import Payments models.
Payments does not own the Case, Workflow, applicant, invoice, journal entry,
evidence binary, or payment-provider transaction.
## Manual reconciliation
The first path supports exactly one full reconciliation. The supplied amount
and currency must equal the obligation. Evidence must belong to the same tenant
and carry a version or checksum so the observation can be reconstructed. The
operator also records the external transaction reference and received time;
the API assigns the recorded time from the server clock. All supplied
timestamps require a timezone.
The request and reconciliation commands are idempotent. Reusing a key with the
same canonical command returns the existing result; changing any material
field conflicts. Once paid, another reconciliation under a different key fails
closed. Corrections, reversals, refunds, chargebacks, partial payments, and
overpayments require future append-only adjustment types and must never mutate
the original evidence silently.
## Operator workspace
The permission-aware `/payments` workspace is the operator projection of this
contract. Readers can filter requested and paid obligations and inspect their
source, Case/Workflow context references, amount, due or settled time, and
immutable evidence reference. Writers create fixed obligations in a guided
dialog; the UI supplies an explicit replay key and never copies applicant or
Form content into Payments.
Reconciliation uses a separate consequential dialog. Amount and currency are
fixed from the selected obligation rather than editable. The operator records
the external transaction reference, receipt time, evidence owner, kind, ID,
and at least one immutable version or checksum. The dialog explains that paid
state cannot be silently undone and that a governed adjustment is required.
Missing permissions remain visible with the exact scope and responsible
administrator.
Reload is always available in the collection action bar. A failed refresh
preserves the last successful result and labels it stale; an initial failure
uses a whole-surface retry state. The workspace also distinguishes loading,
empty, permission-blocked, conflict, replay-success, and ordinary success
states.
## Access, privacy, and audit
Payment readers see obligation and reconciliation metadata. Writers create
obligations, while the separate reconciliation permission records receipt.
The contract needs stable procedure references, not applicant names, bank
account details, or Form values. Evidence bytes remain with Files or another
owner; Payments stores only `EvidenceReference` metadata.
Each state transition writes a Payment event. API transitions also emit Core
audit evidence; installations with Audit retain it through the Audit owner.
Automated capability consumers must retain their own command/effect evidence
and the returned payment ID in the owning Case or Workflow.
## Recovery and retirement
Restore obligations, reconciliations, and events together. Verify one unique
payment ID and payment reference per tenant, one matching reconciliation for
each `paid` obligation, exact amount/currency equality, same-tenant immutable
evidence, and ordered lifecycle events. Replay the original keys and hashes to
confirm they return the restored objects.
Destructive retirement is blocked while any of the three tables contain data.
It requires a verified database snapshot and an explicit records/accounting
decision for the retained evidence references. Ledger posting, XRechnung,
online checkout, callbacks, and provider reconciliation are not implied by this
slice.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/payments-webui",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/payments.css": "./webui/src/styles/payments.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-payments"
version = "0.1.22"
description = "Replay-safe payment obligations and reconciliation evidence for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.37"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_payments = ["py.typed"]
[project.entry-points."govoplan.modules"]
"payments" = "govoplan_payments.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Payments module."""
__version__ = "0.1.22"
@@ -0,0 +1 @@
"""Payments backend."""
@@ -0,0 +1,7 @@
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
__all__ = ["PaymentEvent", "PaymentObligation", "PaymentReconciliation"]
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class PaymentObligation(Base, TimestampMixin):
__tablename__ = "payment_obligations"
__table_args__ = (
UniqueConstraint("tenant_id", "payment_id", name="uq_payment_obligation"),
UniqueConstraint(
"tenant_id",
"source_module",
"idempotency_key",
name="uq_payment_request_idempotency",
),
UniqueConstraint(
"tenant_id", "payment_reference", name="uq_payment_reference"
),
Index(
"ix_payment_obligation_source",
"tenant_id",
"source_module",
"source_resource_type",
"source_resource_id",
),
Index(
"ix_payment_obligation_state",
"tenant_id",
"status",
"requested_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
payment_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
payment_reference: Mapped[str] = mapped_column(
String(32), nullable=False, index=True
)
source_module: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
source_resource_type: Mapped[str] = mapped_column(
String(120), nullable=False, index=True
)
source_resource_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
amount_minor: Mapped[int] = mapped_column(BigInteger, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, index=True)
subject: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
requested_by_ref: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
settled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
context_refs: Mapped[dict[str, str]] = mapped_column(
JSON, default=dict, nullable=False
)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class PaymentReconciliation(Base, TimestampMixin):
__tablename__ = "payment_reconciliations"
__table_args__ = (
UniqueConstraint(
"tenant_id", "reconciliation_id", name="uq_payment_reconciliation"
),
UniqueConstraint(
"tenant_id",
"payment_row_id",
"idempotency_key",
name="uq_payment_reconciliation_idempotency",
),
Index(
"ix_payment_reconciliation_payment",
"tenant_id",
"payment_row_id",
"recorded_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
reconciliation_id: Mapped[str] = mapped_column(
String(36), nullable=False, index=True
)
payment_row_id: Mapped[str] = mapped_column(
ForeignKey("payment_obligations.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
amount_minor: Mapped[int] = mapped_column(BigInteger, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False)
transaction_reference: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
evidence_ref: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
recorded_by_ref: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class PaymentEvent(Base, TimestampMixin):
__tablename__ = "payment_events"
__table_args__ = (
UniqueConstraint("tenant_id", "event_id", name="uq_payment_event"),
Index(
"ix_payment_event_stream",
"tenant_id",
"payment_row_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
payment_row_id: Mapped[str] = mapped_column(
ForeignKey("payment_obligations.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_ref: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = ["PaymentEvent", "PaymentObligation", "PaymentReconciliation"]
@@ -0,0 +1,550 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
PAYMENTS_DSAR_CAPABILITY = dsar_capability_name("payments")
_MAX_RECORDS = 5_000
_MAX_CHILD_RECORDS = 1_000
_CONFLICT = object()
_ATTRIBUTION_TYPES = frozenset(
{
"payment_request_attribution",
"payment_reconciliation_attribution",
"payment_event_attribution",
}
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_refs: tuple[str, ...]
payment_row_id: str | None
payment_id: str | None
payment_reference: str | None
@property
def has_payment_selector(self) -> bool:
return bool(self.payment_row_id or self.payment_id or self.payment_reference)
class PaymentsDsarProvider:
provider_id = "payments"
module_id = "payments"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
if selectors.has_payment_selector:
query = db.query(PaymentObligation).filter(
PaymentObligation.tenant_id == tenant_id
)
if selectors.payment_row_id:
query = query.filter(PaymentObligation.id == selectors.payment_row_id)
if selectors.payment_id:
query = query.filter(
PaymentObligation.payment_id == selectors.payment_id
)
if selectors.payment_reference:
query = query.filter(
PaymentObligation.payment_reference == selectors.payment_reference
)
rows = (
query.order_by(
PaymentObligation.requested_at,
PaymentObligation.id,
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Payments DSAR result limit exceeded; narrow the identifiers."
)
return tuple(_payment_record(db, row) for row in rows)
if not selectors.actor_refs:
return ()
records: list[DsarRecordRef] = []
obligations = db.query(PaymentObligation).filter(
PaymentObligation.tenant_id == tenant_id,
PaymentObligation.requested_by_ref.in_(selectors.actor_refs),
)
records.extend(
_request_attribution(row)
for row in _limited(
obligations,
PaymentObligation,
"request attribution",
)
)
reconciliations = db.query(PaymentReconciliation).filter(
PaymentReconciliation.tenant_id == tenant_id,
PaymentReconciliation.recorded_by_ref.in_(selectors.actor_refs),
)
records.extend(
_reconciliation_attribution(row)
for row in _limited(
reconciliations,
PaymentReconciliation,
"reconciliation attribution",
)
)
events = db.query(PaymentEvent).filter(
PaymentEvent.tenant_id == tenant_id,
PaymentEvent.actor_ref.in_(selectors.actor_refs),
)
records.extend(
_event_attribution(row)
for row in _limited(events, PaymentEvent, "event attribution")
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Payments DSAR combined result limit exceeded; narrow the selectors."
)
order = {
"payment_request_attribution": 10,
"payment_reconciliation_attribution": 20,
"payment_event_attribution": 30,
}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Payments DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"payments:retain:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Financial and reconciliation evidence must be retained.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Payments DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Payments DSAR publishes retain-only actions.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Payment and reconciliation evidence remains under the "
"configured financial, statutory, and legal-hold policy."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("payments.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("payments.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("payments.identity"),
references.get("identity.id"),
),
"actor_ref": _coalesce(
references.get("payments.actor"),
references.get("payments.operator"),
),
"payment_row_id": _coalesce(
references.get("payments.obligation"),
references.get("payments.row"),
),
"payment_id": _coalesce(
references.get("payments.payment"),
references.get("payments.payment_id"),
),
"payment_reference": _coalesce(
references.get("payments.reference"),
references.get("payments.payment_reference"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional_string(values["account_id"])
membership_id = _optional_string(values["membership_id"])
identity_id = _optional_string(values["identity_id"])
direct_actor = _optional_string(values["actor_ref"])
actor_refs = tuple(
dict.fromkeys(
value
for value in (
account_id,
f"account:{account_id}" if account_id else None,
membership_id,
f"membership:{membership_id}" if membership_id else None,
identity_id,
f"identity:{identity_id}" if identity_id else None,
direct_actor,
)
if value
)
)
selectors = _SubjectSelectors(
actor_refs=actor_refs,
payment_row_id=_optional_string(values["payment_row_id"]),
payment_id=_optional_string(values["payment_id"]),
payment_reference=_optional_string(values["payment_reference"]),
)
if not selectors.actor_refs and not selectors.has_payment_selector:
return None
return selectors
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _limited(query, model, label: str):
rows = query.order_by(model.created_at, model.id).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Payments DSAR {label} limit exceeded; narrow the selectors.")
return rows
def _payment_record(
session: Session,
obligation: PaymentObligation,
) -> DsarRecordRef:
reconciliations = _children(
session.query(PaymentReconciliation).filter(
PaymentReconciliation.tenant_id == obligation.tenant_id,
PaymentReconciliation.payment_row_id == obligation.id,
),
PaymentReconciliation,
"reconciliation",
)
events = _children(
session.query(PaymentEvent).filter(
PaymentEvent.tenant_id == obligation.tenant_id,
PaymentEvent.payment_row_id == obligation.id,
),
PaymentEvent,
"event",
)
return DsarRecordRef(
provider_id="payments",
module_id="payments",
resource_type="payment_obligation",
resource_id=obligation.id,
category="financial_obligation_and_evidence",
title=f"Payment obligation {obligation.payment_reference}",
data={
"payment_id": obligation.payment_id,
"payment_reference": obligation.payment_reference,
"source_module": obligation.source_module,
"source_resource_type": obligation.source_resource_type,
"source_resource_id": obligation.source_resource_id,
"amount_minor": obligation.amount_minor,
"currency": obligation.currency,
"subject": obligation.subject[:1_000],
"status": obligation.status,
"requested_at": _iso(obligation.requested_at),
"requested_by_ref": obligation.requested_by_ref,
"due_at": _iso(obligation.due_at),
"settled_at": _iso(obligation.settled_at),
"context_refs": _context_refs(obligation.context_refs),
"reconciliations": [
{
"id": row.id,
"reconciliation_id": row.reconciliation_id,
"mode": row.mode,
"amount_minor": row.amount_minor,
"currency": row.currency,
"transaction_reference": row.transaction_reference,
"evidence_ref": _evidence_reference(row.evidence_ref),
"received_at": _iso(row.received_at),
"recorded_at": _iso(row.recorded_at),
"recorded_by_ref": row.recorded_by_ref,
}
for row in reconciliations
],
"events": [
{
"id": row.id,
"event_id": row.event_id,
"event_type": row.event_type,
"status": row.status,
"occurred_at": _iso(row.occurred_at),
"actor_ref": row.actor_ref,
}
for row in events
],
},
observed_at=_aware(obligation.updated_at),
immutable_evidence=True,
retention_reason=(
"The exact obligation, reconciliation, and lifecycle records are "
"financial evidence. Arbitrary metadata and event payloads are excluded."
),
)
def _children(query, model, label: str):
rows = (
query.order_by(model.created_at, model.id).limit(_MAX_CHILD_RECORDS + 1).all()
)
if len(rows) > _MAX_CHILD_RECORDS:
raise ValueError(
f"Payment {label} history exceeds the DSAR bound; narrow and review the payment."
)
return rows
def _request_attribution(row: PaymentObligation) -> DsarRecordRef:
return _attribution_record(
"payment_request_attribution",
row.id,
"Requested payment obligation",
{
"activity": "requested_payment_obligation",
"payment_id": row.payment_id,
"payment_reference": row.payment_reference,
"source_module": row.source_module,
"source_resource_type": row.source_resource_type,
"source_resource_id": row.source_resource_id,
"amount_minor": row.amount_minor,
"currency": row.currency,
"status": row.status,
"requested_at": _iso(row.requested_at),
},
row.requested_at,
)
def _reconciliation_attribution(row: PaymentReconciliation) -> DsarRecordRef:
return _attribution_record(
"payment_reconciliation_attribution",
row.id,
"Recorded payment reconciliation",
{
"activity": "recorded_payment_reconciliation",
"payment_row_id": row.payment_row_id,
"reconciliation_id": row.reconciliation_id,
"mode": row.mode,
"amount_minor": row.amount_minor,
"currency": row.currency,
"received_at": _iso(row.received_at),
"recorded_at": _iso(row.recorded_at),
},
row.recorded_at,
)
def _event_attribution(row: PaymentEvent) -> DsarRecordRef:
return _attribution_record(
"payment_event_attribution",
row.id,
"Payment lifecycle event attribution",
{
"activity": "recorded_payment_event",
"payment_row_id": row.payment_row_id,
"event_id": row.event_id,
"event_type": row.event_type,
"status": row.status,
"occurred_at": _iso(row.occurred_at),
},
row.occurred_at,
)
def _attribution_record(
resource_type: str,
resource_id: str,
title: str,
data: Mapping[str, object],
observed_at: datetime,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="payments",
module_id="payments",
resource_type=resource_type,
resource_id=resource_id,
category="operator_accountability_evidence",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Payment operator attribution is financial accountability evidence; "
"arbitrary metadata, hashes, replay keys, and payloads are excluded."
),
)
def _context_refs(value: object) -> dict[str, str]:
if not isinstance(value, Mapping) or len(value) > 100:
raise ValueError("Payment context references exceed the DSAR bound.")
result: dict[str, str] = {}
for raw_key, raw_value in value.items():
key = str(raw_key)
if not key or len(key) > 200:
raise ValueError("Payment context reference key is invalid.")
result[key] = "[redacted]" if _sensitive_key(key) else str(raw_value)[:2_000]
return result
def _evidence_reference(value: object) -> dict[str, object]:
if not isinstance(value, Mapping):
raise ValueError("Payment evidence reference is invalid.")
derived = value.get("derived_from")
if not isinstance(derived, list) or len(derived) > 100:
raise ValueError("Payment evidence derivation exceeds the DSAR bound.")
return {
"kind": _bounded(value.get("kind"), 100),
"owner_module": _bounded(value.get("owner_module"), 100),
"evidence_id": _bounded(value.get("evidence_id"), 255),
"tenant_id": _bounded(value.get("tenant_id"), 36),
"version": _bounded(value.get("version"), 255),
"checksum": _bounded(value.get("checksum"), 255),
"source_ref": _bounded(value.get("source_ref"), 2_000),
"derived_from": [_bounded(item, 2_000) for item in derived],
"responsible_actor_ref": _bounded(
value.get("responsible_actor_ref"),
255,
),
"captured_at": _bounded(value.get("captured_at"), 100),
}
def _bounded(value: object, limit: int) -> str | None:
return str(value)[:limit] if value is not None else None
def _sensitive_key(value: str) -> bool:
normalized = value.strip().casefold().replace("-", "_")
return any(
part in normalized
for part in (
"authorization",
"cookie",
"credential",
"password",
"secret",
"token",
)
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Payments DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "payments" or record.module_id != "payments":
raise ValueError("Payments DSAR cannot plan a foreign provider record.")
if record.resource_type not in {"payment_obligation"} | _ATTRIBUTION_TYPES:
raise ValueError("Payments DSAR record type is invalid.")
if not record.resource_id:
raise ValueError("Payments DSAR record identity is incomplete.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "payments" or action.module_id != "payments":
raise ValueError("Payments DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("payments:"):
raise ValueError("Payments DSAR action identity is invalid.")
__all__ = ["PAYMENTS_DSAR_CAPABILITY", "PaymentsDsarProvider"]
+523
View File
@@ -0,0 +1,523 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.payments import CAPABILITY_PAYMENT_REQUESTS
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_payments.backend.db import models as payment_models
from govoplan_payments.backend.dsar_provider import (
PAYMENTS_DSAR_CAPABILITY,
PaymentsDsarProvider,
)
from govoplan_payments.backend.service import SqlPaymentRequestProvider
MODULE_ID = "payments"
MODULE_NAME = "Payments"
MODULE_VERSION = "0.1.22"
READ_SCOPE = "payments:payment:read"
WRITE_SCOPE = "payments:payment:write"
RECONCILE_SCOPE = "payments:payment:reconcile"
ADMIN_SCOPE = "payments:payment:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(_context: ModuleContext):
from govoplan_payments.backend.router import router
return router
def _payment_requests(_context: ModuleContext) -> SqlPaymentRequestProvider:
return SqlPaymentRequestProvider()
def _dsar_provider(_context: ModuleContext) -> PaymentsDsarProvider:
return PaymentsDsarProvider()
def _tenant_summary(session: object, tenant_id: str) -> dict[str, int]:
if not hasattr(session, "query"):
return {"payment_requests": 0, "paid_payments": 0, "reconciliations": 0}
obligations = session.query(payment_models.PaymentObligation).filter(
payment_models.PaymentObligation.tenant_id == tenant_id
)
return {
"payment_requests": obligations.count(),
"paid_payments": obligations.filter(
payment_models.PaymentObligation.status == "paid"
).count(),
"reconciliations": session.query(payment_models.PaymentReconciliation)
.filter(payment_models.PaymentReconciliation.tenant_id == tenant_id)
.count(),
}
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
optional_dependencies=(
"files",
"audit",
"cases",
"workflow_engine",
"ledger",
"xrechnung",
),
permissions=(
_permission(
READ_SCOPE,
"View payment requests",
"View tenant payment obligations, state, and reconciliation evidence.",
),
_permission(
WRITE_SCOPE,
"Create payment requests",
"Create replay-safe payment obligations for an owning procedure.",
),
_permission(
RECONCILE_SCOPE,
"Reconcile manual payments",
"Confirm an exact payment with immutable external evidence.",
),
_permission(
ADMIN_SCOPE,
"Administer Payments",
"Administer payment access, retention, recovery, and future providers.",
),
),
role_templates=(
RoleTemplate(
slug="payment_operator",
name="Payment operator",
description="Create payment obligations and reconcile evidenced receipts.",
permissions=(READ_SCOPE, WRITE_SCOPE, RECONCILE_SCOPE),
),
RoleTemplate(
slug="payment_auditor",
name="Payment auditor",
description="Inspect payment state and reconciliation evidence.",
permissions=(READ_SCOPE,),
),
),
nav_items=(
NavItem(
path="/payments",
label="Payments",
icon="landmark",
required_any=(READ_SCOPE,),
order=73,
surface_id="payments.navigation",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/payments-webui",
routes=(
FrontendRoute(
path="/payments",
component="PaymentsPage",
required_any=(READ_SCOPE,),
order=73,
surface_id="payments.workspace",
),
),
nav_items=(
NavItem(
path="/payments",
label="Payments",
icon="landmark",
required_any=(READ_SCOPE,),
order=73,
surface_id="payments.navigation",
),
),
product_areas=(
ProductAreaContribution(
id="services-cases",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.services_cases",
icon="landmark",
description="i18n:govoplan-core.product_area.services_cases_description",
surface_ids=("payments.navigation", "payments.workspace"),
order=20,
),
),
view_surfaces=(
ViewSurface(
id="payments.request.create",
module_id=MODULE_ID,
kind="section",
label="Create payment request",
parent_id="payments.workspace",
order=30,
),
ViewSurface(
id="payments.reconciliation.manual",
module_id=MODULE_ID,
kind="section",
label="Record manual payment",
parent_id="payments.workspace",
order=40,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_PAYMENT_REQUESTS, version="1.0.0"),
ModuleInterfaceProvider(name=PAYMENTS_DSAR_CAPABILITY, version="0.1.0"),
),
capability_factories={
CAPABILITY_PAYMENT_REQUESTS: _payment_requests,
PAYMENTS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_PAYMENT_REQUESTS: CapabilityDocumentation(
label="Payment request and reconciliation",
summary="Creates replay-safe obligations and records exact, evidence-bound manual settlement.",
contract_version="1.0.0",
),
PAYMENTS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Payments data-subject request provider",
summary=(
"Exports exact verified payment evidence or minimized operator "
"attribution with retain-only erasure outcomes."
),
contract_version="0.1.0",
),
},
route_factory=_router,
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
payment_models.PaymentEvent,
payment_models.PaymentReconciliation,
payment_models.PaymentObligation,
label=MODULE_NAME,
),
retirement_notes=(
"Destructive retirement removes payment obligations and reconciliation evidence "
"and requires a verified database snapshot plus an accounting/records decision."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
payment_models.PaymentEvent,
payment_models.PaymentReconciliation,
payment_models.PaymentObligation,
label=MODULE_NAME,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="payments.data-subject-requests",
title="Payment data-subject requests",
summary=(
"Export exact payment obligations and financial evidence without "
"using payment descriptions as an identity search surface."
),
body=(
"Payments has no resident or applicant identity column and does not "
"search payment subjects, context JSON, metadata, or source records "
"for a person. Full financial access therefore requires an exact "
"payment row id, payment id, or human payment reference supplied as "
"a verified external subject reference. The resulting package contains "
"the obligation amount, currency, subject, status, dates, source and "
"bounded context references, reconciliation facts and typed evidence "
"references, and lifecycle-event facts. Reconciliation metadata, event "
"payloads, hashes, replay keys, provider data, inspection URLs, and "
"credentials are excluded. A request containing only an account, "
"membership, identity, or exact actor reference receives minimized "
"request, reconciliation, and event attribution for that operator; it "
"does not expose payment subjects. Every result is exact-tenant and "
"bounded. All erasure actions are retain-only and non-executable because "
"obligations, settlements, evidence links, and lifecycle attribution "
"remain governed financial and statutory evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("data_subject", "operator", "auditor", "module_admin"),
related_modules=("core", "cases", "workflow_engine", "ledger"),
metadata={
"kind": "reference",
"help_contexts": [
"payments.workspace",
"payments.state.requested",
"payments.state.paid",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_exact_payment": (
"Returns the obligation and bounded financial evidence for a "
"verified exact payment identifier."
),
"export_operator_attribution": (
"Returns minimized financial activity, never arbitrary payment "
"content."
),
"retain_payment_evidence": (
"Keeps financial evidence under configured statutory retention "
"and legal hold."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Zahlungen",
"summary": (
"Exakte Zahlungsverpflichtungen und Finanznachweise exportieren, ohne "
"Zahlungsbeschreibungen als Identitätssuchfläche zu verwenden."
),
"body": (
"Payments besitzt keine Spalte für Einwohner- oder Antragstelleridentitäten und "
"durchsucht weder Zahlungsbetreffe noch Kontext-JSON, Metadaten oder Quelldatensätze "
"nach einer Person. Eine vollständige Finanzauskunft erfordert deshalb eine exakte "
"Zahlungszeilenkennung, Zahlungskennung oder menschenlesbare Zahlungsreferenz, die als "
"verifizierte externe Betroffenenreferenz bereitgestellt wird. Das Auskunftspaket enthält "
"Verpflichtungsbetrag, Währung, Betreff, Status, Zeitpunkte, Quell- und begrenzte "
"Kontextreferenzen, Abstimmungsfakten, typisierte Nachweisreferenzen und Fakten zu "
"Lebenszyklusereignissen. Abstimmungsmetadaten, Ereignisinhalte, Prüfsummen, "
"Wiederholungsschlüssel, Anbieterdaten, Prüf-URLs und Zugangsdaten bleiben ausgeschlossen. "
"Eine Anfrage nur mit Konto-, Mitgliedschafts-, Identitäts- oder exakter Akteursreferenz "
"liefert minimierte Zuschreibungen zu Anforderung, Abstimmung und Ereignissen dieser "
"bearbeitenden Person; Zahlungsbetreffe werden nicht offengelegt. Jedes Ergebnis ist exakt "
"mandantenbegrenzt. Alle Löschaktionen sind reine Aufbewahrungsergebnisse und nicht "
"ausführbar, weil Verpflichtungen, Erfüllungen, Nachweisverknüpfungen und "
"Lebenszykluszuschreibungen gesteuerte finanzielle und gesetzliche Nachweise bleiben."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_exact_payment": (
"Gibt Verpflichtung und begrenzte Finanznachweise für eine verifizierte exakte Zahlungskennung zurück."
),
"export_operator_attribution": (
"Gibt minimierte Finanzaktivität, aber niemals beliebige Zahlungsinhalte zurück."
),
"retain_payment_evidence": (
"Bewahrt Finanznachweise gemäß konfigurierter gesetzlicher Aufbewahrung und Sperre auf."
),
}
}
},
),
DocumentationTopic(
id="payments.requests-and-reconciliation",
title="Payment requests and manual reconciliation",
summary="Create an exact obligation and mark it paid only with matching, immutable evidence.",
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Payment requests, not among operational action buttons. Field help remains beside its label. "
"Payments owns the tenant-bound payment ID, human payment reference, requested amount and currency, lifecycle events, and reconciliation evidence. "
"A Case, Workflow, or other procedure calls the payments.requests capability with its own source reference and a replay key; it keeps the returned payment ID instead of writing Payments tables. "
"The Payments workspace lists requested and paid obligations with source, due or settled times, and reconciliation evidence. A writer creates a request through the guided dialog; a reconciler uses the separate consequential dialog, which fixes the amount and currency and requires an external transaction reference plus a same-tenant versioned or checksum-bound EvidenceReference. Reload preserves loaded data and marks it stale when refresh fails. Missing create or reconciliation authority remains visible with the required permission and responsible administrator. "
"The first supported receipt path is manual reconciliation of a full payment. A mismatch, duplicate settlement under another key, cross-tenant evidence, partial amount, or timezone-free timestamp fails closed. "
"Successful requests and reconciliations append payment events and API actions add audit evidence when Audit is installed. There is no silent correction: reversal, refund, partial payment, online checkout, provider callbacks, Ledger posting, and XRechnung remain explicit future flows."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "auditor", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=("cases", "workflow_engine", "audit", "ledger"),
links=(
DocumentationLink(
label="Payments boundary and recovery",
href="govoplan-payments/docs/PAYMENTS_DOMAIN.md",
kind="repository",
),
),
metadata={
"kind": "workflow",
"help_contexts": [
"payments.request",
"payments.workspace",
"payments.request.create",
"payments.reconciliation.manual",
"payments.state.requested",
"payments.state.paid",
],
"purpose": (
"Create an exact payment obligation and reconcile it as paid only against matching immutable evidence."
),
"prerequisites": [
"The actor can read Payments; creating and reconciling require their dedicated scopes.",
"The source procedure supplies a stable same-tenant reference and a replay-safe request key.",
"Manual reconciliation has a same-tenant versioned or checksum-bound evidence reference.",
],
"steps": [
"Create the amount, currency, source reference, due date, and human payment reference in the guided dialog.",
"Retain the returned payment ID in the calling Case or Workflow instead of writing Payments tables.",
"Reload the obligation before reconciliation when the workspace reports stale data.",
"Provide the external transaction reference, exact settlement time, and immutable evidence reference.",
"Confirm full amount and currency; any mismatch, duplicate, partial amount, or cross-tenant evidence fails closed.",
"Review the appended lifecycle and audit evidence after the obligation becomes paid.",
],
"limitations": [
"Only full manual reconciliation is supported; partial payment, refund, reversal, and correction need future governed flows.",
"Online checkout, callbacks, Ledger posting, XRechnung, and an applicant payment page are not implemented here.",
],
"privacy_notes": [
"Procedure context uses stable references; applicant names, bank account details, and submitted form values are not required.",
"The immutable evidence remains owned by its provider; Payments stores only the typed EvidenceReference.",
],
"consequence_classes": {
"request_payment": "Creates a durable amount/currency obligation and a stable applicant payment reference.",
"reconcile_manual": "Marks the exact obligation paid and appends evidence; a future governed adjustment is required to reverse it.",
},
"verification": [
"The paid obligation retains the original amount, currency, payment ID, and human reference unchanged.",
"Reconciliation names the external transaction and immutable evidence reference.",
"Replay and duplicate checks prove that one external settlement did not create conflicting paid states.",
],
},
translations={
"de": {
"title": "Zahlungsanforderungen und manuelle Abstimmung",
"summary": (
"Eine exakte Verpflichtung anlegen und nur mit passendem unveränderlichem "
"Nachweis als bezahlt kennzeichnen."
),
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Zahlungsanforderungen, nicht zwischen ausführbaren "
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Payments führt die mandantengebundene Zahlungskennung, die menschenlesbare "
"Zahlungsreferenz, angeforderten Betrag und Währung, Lebenszyklusereignisse und "
"Abstimmungsnachweise. Ein Case, Workflow oder anderes Verfahren ruft die Fähigkeit "
"payments.requests mit eigener Quellreferenz und Wiederholungsschlüssel auf und bewahrt "
"die zurückgegebene Zahlungskennung auf, statt Payments-Tabellen zu schreiben. Der "
"Arbeitsbereich zeigt angeforderte und bezahlte Verpflichtungen mit Quelle, Fälligkeit "
"oder Erfüllungszeit und Abstimmungsnachweis. Schreibberechtigte legen eine Anforderung im "
"geführten Dialog an. Abstimmungsberechtigte verwenden den getrennten folgenreichen Dialog, "
"der Betrag und Währung fixiert und eine externe Transaktionsreferenz sowie eine "
"mandantengleiche versionierte oder prüfsummengebundene EvidenceReference verlangt. Neu "
"laden erhält vorhandene Daten und kennzeichnet sie als veraltet, wenn die Aktualisierung "
"scheitert. Fehlende Anlege- oder Abstimmungsberechtigung bleibt mit erforderlicher "
"Berechtigung und zuständiger Administration sichtbar. Der erste unterstützte Zahlungseingang "
"ist die manuelle Abstimmung einer vollständigen Zahlung. Abweichung, doppelte Erfüllung unter "
"anderem Schlüssel, mandantenfremder Nachweis, Teilbetrag oder Zeitstempel ohne Zeitzone "
"scheitert geschlossen. Erfolgreiche Anforderungen und Abstimmungen fügen "
"Zahlungsereignisse an; API-Aktionen erzeugen bei installiertem Audit Nachweise. Es gibt keine "
"stille Korrektur: Storno, Erstattung, Teilzahlung, Online-Checkout, Anbieter-Callbacks, "
"Ledger-Buchung und XRechnung bleiben ausdrückliche zukünftige Abläufe."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Eine exakte Zahlungsverpflichtung anlegen und nur anhand passender unveränderlicher Nachweise als bezahlt abstimmen."
),
"prerequisites": [
"Die handelnde Person darf Payments lesen; Anlegen und Abstimmen erfordern ihre jeweils eigenen Berechtigungen.",
"Das Quellverfahren liefert eine stabile mandantengleiche Referenz und einen wiederholungssicheren Anforderungsschlüssel.",
"Für die manuelle Abstimmung liegt eine mandantengleiche versionierte oder prüfsummengebundene Nachweisreferenz vor.",
],
"steps": [
"Betrag, Währung, Quellreferenz, Fälligkeit und menschenlesbare Zahlungsreferenz im geführten Dialog anlegen.",
"Die zurückgegebene Zahlungskennung im aufrufenden Case oder Workflow bewahren, statt Payments-Tabellen zu schreiben.",
"Die Verpflichtung vor der Abstimmung neu laden, wenn der Arbeitsbereich veraltete Daten meldet.",
"Externe Transaktionsreferenz, exakte Erfüllungszeit und unveränderliche Nachweisreferenz angeben.",
"Vollständigen Betrag und Währung bestätigen; Abweichung, Duplikat, Teilbetrag oder mandantenfremder Nachweis scheitert geschlossen.",
"Nach dem Wechsel auf bezahlt die angefügten Lebenszyklus- und Auditnachweise prüfen.",
],
"limitations": [
"Nur vollständige manuelle Abstimmung wird unterstützt; Teilzahlung, Erstattung, Storno und Korrektur benötigen zukünftige gesteuerte Abläufe.",
"Online-Checkout, Callbacks, Ledger-Buchung, XRechnung und eine Antragsteller-Zahlungsseite sind hier nicht implementiert.",
],
"privacy_notes": [
"Verfahrenskontext verwendet stabile Referenzen; Namen von Antragstellern, Bankverbindungen und übermittelte Formularwerte sind nicht erforderlich.",
"Der unveränderliche Nachweis bleibt Eigentum seines Anbieters; Payments speichert nur die typisierte EvidenceReference.",
],
"consequence_classes": {
"request_payment": "Erzeugt eine dauerhafte Betrags- und Währungsverpflichtung sowie eine stabile Zahlungsreferenz für Antragsteller.",
"reconcile_manual": "Kennzeichnet die exakte Verpflichtung als bezahlt und fügt Nachweise an; eine zukünftige gesteuerte Anpassung ist zur Umkehr erforderlich.",
},
"verification": [
"Die bezahlte Verpflichtung bewahrt ursprünglichen Betrag, Währung, Zahlungskennung und menschenlesbare Referenz unverändert.",
"Die Abstimmung nennt externe Transaktion und unveränderliche Nachweisreferenz.",
"Wiederholungs- und Duplikatprüfungen belegen, dass eine externe Erfüllung keine widersprüchlichen Bezahltzustände erzeugt hat.",
],
}
},
),
),
architecture=declared_module_architecture(
layer="domain_capability",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/PAYMENTS_DOMAIN.md",
test_ref="tests/test_payments.py",
known_limits=(
"Only full manual payment reconciliation is implemented; partial payments, refunds, reversals, and corrections need explicit governed flows.",
"No online payment provider, callback, ledger posting, XRechnung, or applicant payment page is included yet; the operator workspace covers fixed requests and full manual reconciliation only.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"payment obligation",
"payment reference",
"payment reconciliation",
"payment lifecycle evidence",
),
non_owned_concepts=(
"case",
"workflow",
"invoice",
"accounting entry",
"evidence binary",
"external payment execution",
),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/PAYMENTS_DOMAIN.md",),
recovery_docs=("docs/PAYMENTS_DOMAIN.md",),
security_docs=("docs/PAYMENTS_DOMAIN.md",),
operations_docs=("docs/PAYMENTS_DOMAIN.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Payments migrations."""
@@ -0,0 +1,194 @@
"""Add replay-safe payment obligations and manual reconciliation evidence.
Revision ID: e7b9c1d3f5a7
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e7b9c1d3f5a7"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"payment_obligations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("payment_id", sa.String(length=36), nullable=False),
sa.Column("payment_reference", sa.String(length=32), nullable=False),
sa.Column("source_module", sa.String(length=120), nullable=False),
sa.Column("source_resource_type", sa.String(length=120), nullable=False),
sa.Column("source_resource_id", sa.String(length=255), nullable=False),
sa.Column("amount_minor", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("subject", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("requested_by_ref", sa.String(length=255), nullable=False),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("settled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("context_refs", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_payment_obligations")),
sa.UniqueConstraint("tenant_id", "payment_id", name="uq_payment_obligation"),
sa.UniqueConstraint(
"tenant_id",
"source_module",
"idempotency_key",
name="uq_payment_request_idempotency",
),
sa.UniqueConstraint(
"tenant_id", "payment_reference", name="uq_payment_reference"
),
)
for column in (
"tenant_id",
"payment_id",
"payment_reference",
"source_module",
"source_resource_type",
"source_resource_id",
"currency",
"status",
"requested_at",
"requested_by_ref",
"due_at",
"settled_at",
):
op.create_index(
op.f(f"ix_payment_obligations_{column}"),
"payment_obligations",
[column],
unique=False,
)
op.create_index(
"ix_payment_obligation_source",
"payment_obligations",
["tenant_id", "source_module", "source_resource_type", "source_resource_id"],
unique=False,
)
op.create_index(
"ix_payment_obligation_state",
"payment_obligations",
["tenant_id", "status", "requested_at"],
unique=False,
)
op.create_table(
"payment_reconciliations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("reconciliation_id", sa.String(length=36), nullable=False),
sa.Column("payment_row_id", sa.String(length=36), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False),
sa.Column("amount_minor", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("transaction_reference", sa.String(length=255), nullable=False),
sa.Column("evidence_ref", sa.JSON(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("recorded_by_ref", sa.String(length=255), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["payment_row_id"],
["payment_obligations.id"],
name=op.f("fk_payment_reconciliations_payment_row_id_payment_obligations"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_payment_reconciliations")),
sa.UniqueConstraint(
"tenant_id", "reconciliation_id", name="uq_payment_reconciliation"
),
sa.UniqueConstraint(
"tenant_id",
"payment_row_id",
"idempotency_key",
name="uq_payment_reconciliation_idempotency",
),
)
for column in (
"tenant_id",
"reconciliation_id",
"payment_row_id",
"mode",
"transaction_reference",
"received_at",
"recorded_at",
"recorded_by_ref",
):
op.create_index(
op.f(f"ix_payment_reconciliations_{column}"),
"payment_reconciliations",
[column],
unique=False,
)
op.create_index(
"ix_payment_reconciliation_payment",
"payment_reconciliations",
["tenant_id", "payment_row_id", "recorded_at"],
unique=False,
)
op.create_table(
"payment_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("payment_row_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_ref", sa.String(length=255), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["payment_row_id"],
["payment_obligations.id"],
name=op.f("fk_payment_events_payment_row_id_payment_obligations"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_payment_events")),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_payment_event"),
)
for column in (
"tenant_id",
"event_id",
"payment_row_id",
"event_type",
"status",
"occurred_at",
"actor_ref",
):
op.create_index(
op.f(f"ix_payment_events_{column}"),
"payment_events",
[column],
unique=False,
)
op.create_index(
"ix_payment_event_stream",
"payment_events",
["tenant_id", "payment_row_id", "occurred_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("payment_events")
op.drop_table("payment_reconciliations")
op.drop_table("payment_obligations")
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import EvidenceReference, InstitutionalContextError
from govoplan_core.core.payments import (
ManualPaymentReconciliationCommand,
PaymentRequestCommand,
)
from govoplan_core.db.session import get_session
from govoplan_payments.backend.manifest import (
READ_SCOPE,
RECONCILE_SCOPE,
WRITE_SCOPE,
)
from govoplan_payments.backend.schemas import (
ManualPaymentReconciliationCreate,
PaymentListResponse,
PaymentRequestCreate,
)
from govoplan_payments.backend.service import (
PaymentConflict,
PaymentError,
SqlPaymentRequestProvider,
)
router = APIRouter(prefix="/payments", tags=["payments"])
provider = SqlPaymentRequestProvider()
@router.get("/requests", response_model=PaymentListResponse)
def api_list_payment_requests(
payment_status: str | None = Query(default=None, alias="status"),
source_resource_id: str | None = Query(default=None, max_length=255),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PaymentListResponse:
_require(principal, READ_SCOPE)
try:
items = provider.list_payments(
session,
tenant_id=principal.tenant_id,
status=payment_status,
source_resource_id=source_resource_id,
limit=limit,
)
except PaymentError as exc:
raise _error(exc) from exc
return PaymentListResponse(payments=[dict(item) for item in items])
@router.get("/requests/{payment_id}", response_model=dict[str, Any])
def api_get_payment_request(
payment_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
item = provider.get_payment(
session,
tenant_id=principal.tenant_id,
payment_id=payment_id,
)
if item is None:
raise HTTPException(status_code=404, detail="Payment request not found")
return dict(item)
@router.post(
"/requests",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_create_payment_request(
payload: PaymentRequestCreate,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
try:
item = provider.request_payment(
session,
PaymentRequestCommand(
tenant_id=principal.tenant_id,
source_module=payload.source_module,
source_resource_type=payload.source_resource_type,
source_resource_id=payload.source_resource_id,
amount_minor=payload.amount_minor,
currency=payload.currency,
subject=payload.subject,
idempotency_key=payload.idempotency_key,
requested_at=datetime.now(UTC),
requested_by_ref=_actor_ref(principal),
due_at=payload.due_at,
context_refs=payload.context_refs,
metadata=payload.metadata,
),
)
_audit(
session,
principal,
action="payments.requested",
payment=item,
)
session.commit()
except (PaymentError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return dict(item)
@router.post(
"/requests/{payment_id}/manual-reconciliations",
response_model=dict[str, Any],
)
def api_reconcile_manual_payment(
payment_id: str,
payload: ManualPaymentReconciliationCreate,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RECONCILE_SCOPE)
try:
item = provider.reconcile_manual_payment(
session,
ManualPaymentReconciliationCommand(
tenant_id=principal.tenant_id,
payment_id=payment_id,
amount_minor=payload.amount_minor,
currency=payload.currency,
transaction_reference=payload.transaction_reference,
evidence_ref=EvidenceReference.from_mapping(payload.evidence_ref),
idempotency_key=payload.idempotency_key,
received_at=payload.received_at,
recorded_at=datetime.now(UTC),
recorded_by_ref=_actor_ref(principal),
metadata=payload.metadata,
),
)
_audit(
session,
principal,
action="payments.reconciled.manual",
payment=item,
)
session.commit()
except (PaymentError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return dict(item)
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _actor_ref(principal: ApiPrincipal) -> str:
if principal.api_key_id:
return f"api_key:{principal.api_key_id}"
account_id = str(getattr(principal, "account_id", "") or "").strip()
if account_id:
return f"account:{account_id}"
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "").strip()
if user_id:
return f"user:{user_id}"
raise PaymentError("Payment action requires an acting identity.")
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
payment: dict[str, object] | Any,
) -> None:
item = dict(payment)
source = item.get("source") if isinstance(item.get("source"), dict) else {}
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(getattr(principal, "user", None), "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type="payment",
object_id=str(item.get("payment_id") or ""),
details={
"payment_reference": item.get("payment_reference"),
"status": item.get("status"),
"amount_minor": item.get("amount_minor"),
"currency": item.get("currency"),
"source_module": source.get("module"),
"source_resource_type": source.get("resource_type"),
"source_resource_id": source.get("resource_id"),
"replayed": item.get("replayed"),
},
)
def _error(exc: Exception) -> HTTPException:
return HTTPException(
status_code=409 if isinstance(exc, PaymentConflict) else 400,
detail=str(exc),
)
__all__ = ["router"]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class PaymentRequestCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
source_module: str = Field(min_length=1, max_length=120)
source_resource_type: str = Field(min_length=1, max_length=120)
source_resource_id: str = Field(min_length=1, max_length=255)
amount_minor: int = Field(ge=1, le=9_000_000_000_000)
currency: str = Field(min_length=3, max_length=3)
subject: str = Field(min_length=1, max_length=1000)
idempotency_key: str = Field(min_length=1, max_length=255)
due_at: datetime | None = None
context_refs: dict[str, str] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class ManualPaymentReconciliationCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
amount_minor: int = Field(ge=1, le=9_000_000_000_000)
currency: str = Field(min_length=3, max_length=3)
transaction_reference: str = Field(min_length=1, max_length=255)
evidence_ref: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
received_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class PaymentListResponse(BaseModel):
payments: list[dict[str, Any]]
__all__ = [
"ManualPaymentReconciliationCreate",
"PaymentListResponse",
"PaymentRequestCreate",
]
+434
View File
@@ -0,0 +1,434 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
import hashlib
import json
import re
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.payments import (
ManualPaymentReconciliationCommand,
PaymentRequestCommand,
)
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
PAYMENT_STATES = frozenset({"requested", "paid"})
_CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
_MODULE_RE = re.compile(r"^[a-z][a-z0-9_]*$")
class PaymentError(ValueError):
pass
class PaymentConflict(PaymentError):
pass
class SqlPaymentRequestProvider:
def request_payment(
self,
session: Session,
command: PaymentRequestCommand,
) -> Mapping[str, object]:
normalized = _normalized_request(command)
digest = _digest(normalized)
tenant_id = str(normalized["tenant_id"])
source_module = str(normalized["source_module"])
idempotency_key = str(normalized["idempotency_key"])
existing = (
session.query(PaymentObligation)
.filter(
PaymentObligation.tenant_id == tenant_id,
PaymentObligation.source_module == source_module,
PaymentObligation.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None:
if existing.request_sha256 != digest:
raise PaymentConflict(
"Payment request idempotency conflict: the key was already used for a different request."
)
return payment_payload(session, existing, replayed=True)
payment_id = str(uuid.uuid4())
item = PaymentObligation(
tenant_id=tenant_id,
payment_id=payment_id,
payment_reference=f"PAY-{payment_id.replace('-', '')[:12].upper()}",
source_module=source_module,
source_resource_type=str(normalized["source_resource_type"]),
source_resource_id=str(normalized["source_resource_id"]),
amount_minor=int(normalized["amount_minor"]),
currency=str(normalized["currency"]),
subject=str(normalized["subject"]),
status="requested",
idempotency_key=idempotency_key,
request_sha256=digest,
requested_at=command.requested_at,
requested_by_ref=str(normalized["requested_by_ref"]),
due_at=command.due_at,
context_refs=dict(normalized["context_refs"]),
details=dict(normalized["metadata"]),
)
session.add(item)
session.flush()
session.add(
PaymentEvent(
tenant_id=item.tenant_id,
event_id=str(uuid.uuid4()),
payment_row_id=item.id,
event_type="payments.requested",
status=item.status,
occurred_at=command.requested_at,
actor_ref=str(normalized["requested_by_ref"]),
payload={
"source_module": item.source_module,
"source_resource_type": item.source_resource_type,
"source_resource_id": item.source_resource_id,
"amount_minor": item.amount_minor,
"currency": item.currency,
},
)
)
session.flush()
return payment_payload(session, item)
def get_payment(
self,
session: Session,
*,
tenant_id: str,
payment_id: str,
) -> Mapping[str, object] | None:
item = (
session.query(PaymentObligation)
.filter(
PaymentObligation.tenant_id == _text(tenant_id, "Tenant", 36),
PaymentObligation.payment_id
== _text(payment_id, "Payment ID", 36),
)
.one_or_none()
)
return payment_payload(session, item) if item is not None else None
def list_payments(
self,
session: Session,
*,
tenant_id: str,
status: str | None = None,
source_resource_id: str | None = None,
limit: int = 100,
) -> tuple[Mapping[str, object], ...]:
query = session.query(PaymentObligation).filter(
PaymentObligation.tenant_id == _text(tenant_id, "Tenant", 36)
)
if status is not None:
if status not in PAYMENT_STATES:
raise PaymentError(f"Unsupported payment status: {status!r}.")
query = query.filter(PaymentObligation.status == status)
if source_resource_id:
query = query.filter(
PaymentObligation.source_resource_id == source_resource_id
)
return tuple(
payment_payload(session, item)
for item in query.order_by(PaymentObligation.requested_at.desc()).limit(
max(1, min(int(limit), 200))
)
)
def reconcile_manual_payment(
self,
session: Session,
command: ManualPaymentReconciliationCommand,
) -> Mapping[str, object]:
normalized = _normalized_reconciliation(command)
digest = _digest(normalized)
tenant_id = str(normalized["tenant_id"])
payment_id = str(normalized["payment_id"])
idempotency_key = str(normalized["idempotency_key"])
item = (
session.query(PaymentObligation)
.filter(
PaymentObligation.tenant_id == tenant_id,
PaymentObligation.payment_id == payment_id,
)
.with_for_update()
.one_or_none()
)
if item is None:
raise PaymentError("Payment request is unavailable.")
existing = (
session.query(PaymentReconciliation)
.filter(
PaymentReconciliation.tenant_id == tenant_id,
PaymentReconciliation.payment_row_id == item.id,
PaymentReconciliation.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None:
if existing.request_sha256 != digest:
raise PaymentConflict(
"Payment reconciliation idempotency conflict: the key was already used for different evidence."
)
return payment_payload(session, item, replayed=True)
if item.status == "paid":
raise PaymentConflict(
"Payment is already reconciled; correct or reverse it through a future governed adjustment flow."
)
if command.amount_minor != item.amount_minor or command.currency.upper() != item.currency:
raise PaymentConflict(
"Manual reconciliation must match the requested amount and currency exactly."
)
reconciliation = PaymentReconciliation(
tenant_id=tenant_id,
reconciliation_id=str(uuid.uuid4()),
payment_row_id=item.id,
mode="manual",
amount_minor=int(normalized["amount_minor"]),
currency=str(normalized["currency"]),
transaction_reference=str(normalized["transaction_reference"]),
evidence_ref=command.evidence_ref.to_dict(),
idempotency_key=idempotency_key,
request_sha256=digest,
received_at=command.received_at,
recorded_at=command.recorded_at,
recorded_by_ref=str(normalized["recorded_by_ref"]),
details=dict(normalized["metadata"]),
)
session.add(reconciliation)
item.status = "paid"
item.settled_at = command.received_at
session.add(item)
session.flush()
session.add(
PaymentEvent(
tenant_id=item.tenant_id,
event_id=str(uuid.uuid4()),
payment_row_id=item.id,
event_type="payments.reconciled.manual",
status=item.status,
occurred_at=command.recorded_at,
actor_ref=command.recorded_by_ref,
payload={
"reconciliation_id": reconciliation.reconciliation_id,
"received_at": command.received_at.isoformat(),
"evidence_owner_module": command.evidence_ref.owner_module,
"evidence_id": command.evidence_ref.evidence_id,
},
)
)
session.flush()
return payment_payload(session, item)
def payment_payload(
session: Session,
item: PaymentObligation,
*,
replayed: bool = False,
) -> dict[str, object]:
reconciliation = (
session.query(PaymentReconciliation)
.filter(PaymentReconciliation.payment_row_id == item.id)
.order_by(PaymentReconciliation.recorded_at.desc())
.first()
)
events = (
session.query(PaymentEvent)
.filter(PaymentEvent.payment_row_id == item.id)
.order_by(PaymentEvent.occurred_at.asc())
.all()
)
return {
"payment_id": item.payment_id,
"tenant_id": item.tenant_id,
"payment_reference": item.payment_reference,
"source": {
"module": item.source_module,
"resource_type": item.source_resource_type,
"resource_id": item.source_resource_id,
},
"amount_minor": item.amount_minor,
"currency": item.currency,
"subject": item.subject,
"status": item.status,
"requested_at": _aware(item.requested_at).isoformat(),
"requested_by_ref": item.requested_by_ref,
"due_at": _iso(item.due_at),
"settled_at": _iso(item.settled_at),
"context_refs": dict(item.context_refs or {}),
"metadata": dict(item.details or {}),
"reconciliation": (
{
"reconciliation_id": reconciliation.reconciliation_id,
"mode": reconciliation.mode,
"amount_minor": reconciliation.amount_minor,
"currency": reconciliation.currency,
"transaction_reference": reconciliation.transaction_reference,
"evidence_ref": dict(reconciliation.evidence_ref),
"received_at": _aware(reconciliation.received_at).isoformat(),
"recorded_at": _aware(reconciliation.recorded_at).isoformat(),
"recorded_by_ref": reconciliation.recorded_by_ref,
}
if reconciliation is not None
else None
),
"events": [
{
"event_id": event.event_id,
"event_type": event.event_type,
"status": event.status,
"occurred_at": _aware(event.occurred_at).isoformat(),
"actor_ref": event.actor_ref,
"payload": dict(event.payload or {}),
}
for event in events
],
"replayed": replayed,
}
def _normalized_request(command: PaymentRequestCommand) -> dict[str, object]:
tenant_id = _text(command.tenant_id, "Tenant", 36)
source_module = _text(command.source_module, "Source module", 120)
if not _MODULE_RE.fullmatch(source_module):
raise PaymentError("Payment source module must be a valid module ID.")
source_resource_type = _text(
command.source_resource_type, "Source resource type", 120
)
source_resource_id = _text(command.source_resource_id, "Source resource ID", 255)
_amount(command.amount_minor)
_currency(command.currency)
subject = _text(command.subject, "Payment subject", 1000)
idempotency_key = _text(command.idempotency_key, "Idempotency key", 255)
requested_by_ref = _text(command.requested_by_ref, "Request actor", 255)
_aware_required(command.requested_at, "Requested time")
if command.due_at is not None:
_aware_required(command.due_at, "Due time")
if command.due_at < command.requested_at:
raise PaymentError("Payment due time cannot precede the request.")
context_refs = _context_refs(command.context_refs)
return {
"tenant_id": tenant_id,
"source_module": source_module,
"source_resource_type": source_resource_type,
"source_resource_id": source_resource_id,
"amount_minor": command.amount_minor,
"currency": command.currency.upper(),
"subject": subject,
"idempotency_key": idempotency_key,
"requested_at": command.requested_at.isoformat(),
"requested_by_ref": requested_by_ref,
"due_at": command.due_at.isoformat() if command.due_at else None,
"context_refs": context_refs,
"metadata": dict(command.metadata),
}
def _normalized_reconciliation(
command: ManualPaymentReconciliationCommand,
) -> dict[str, object]:
tenant_id = _text(command.tenant_id, "Tenant", 36)
payment_id = _text(command.payment_id, "Payment ID", 36)
_amount(command.amount_minor)
_currency(command.currency)
transaction_reference = _text(
command.transaction_reference, "Transaction reference", 255
)
idempotency_key = _text(command.idempotency_key, "Idempotency key", 255)
recorded_by_ref = _text(command.recorded_by_ref, "Recording actor", 255)
_aware_required(command.received_at, "Payment received time")
_aware_required(command.recorded_at, "Reconciliation recorded time")
evidence = command.evidence_ref
if evidence.tenant_id != tenant_id:
raise PaymentError("Payment evidence belongs to another tenant.")
if not evidence.version and not evidence.checksum:
raise PaymentError(
"Manual reconciliation requires versioned or checksum-bound evidence."
)
return {
"tenant_id": tenant_id,
"payment_id": payment_id,
"amount_minor": command.amount_minor,
"currency": command.currency.upper(),
"transaction_reference": transaction_reference,
"idempotency_key": idempotency_key,
"evidence_ref": evidence.to_dict(),
"received_at": command.received_at.isoformat(),
"recorded_at": command.recorded_at.isoformat(),
"recorded_by_ref": recorded_by_ref,
"metadata": dict(command.metadata),
}
def _digest(payload: Mapping[str, object]) -> str:
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _context_refs(value: Mapping[str, str]) -> dict[str, str]:
if len(value) > 20:
raise PaymentError("Payment context accepts at most 20 references.")
return {
_text(key, "Context reference key", 120): _text(
reference, "Context reference", 255
)
for key, reference in value.items()
}
def _text(value: object, label: str, max_length: int) -> str:
candidate = str(value or "").strip()
if not candidate or len(candidate) > max_length:
raise PaymentError(f"{label} must contain 1 to {max_length} characters.")
return candidate
def _amount(value: int) -> None:
if (
isinstance(value, bool)
or not isinstance(value, int)
or not 1 <= value <= 9_000_000_000_000
):
raise PaymentError("Payment amount must be a positive integer in minor units.")
def _currency(value: str) -> None:
if not _CURRENCY_RE.fullmatch(str(value or "").strip().upper()):
raise PaymentError("Payment currency must be a three-letter ISO code.")
def _aware_required(value: datetime, label: str) -> None:
if value.tzinfo is None:
raise PaymentError(f"{label} must include a timezone.")
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat() if value is not None else None
__all__ = [
"PAYMENT_STATES",
"PaymentConflict",
"PaymentError",
"SqlPaymentRequestProvider",
"payment_payload",
]
+1
View File
@@ -0,0 +1 @@
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
user_workflow_scope_condition_issues,
)
from govoplan_payments.backend.manifest import manifest
class PaymentsDocumentationTests(unittest.TestCase):
def test_public_topics_have_complete_german_reference_content(self) -> None:
self.assertEqual(2, len(manifest.documentation))
for topic in manifest.documentation:
translation = topic.translations.get("de", {})
self.assertTrue(
all(translation.get(key) for key in ("title", "summary", "body"))
)
self.assertEqual((), documentation_structured_translation_issues(topic))
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in manifest.documentation:
self.assertEqual((), user_workflow_scope_condition_issues(topic))
if __name__ == "__main__":
unittest.main()
+418
View File
@@ -0,0 +1,418 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
from govoplan_payments.backend.dsar_provider import (
PAYMENTS_DSAR_CAPABILITY,
PaymentsDsarProvider,
)
from govoplan_payments.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 10, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: PaymentsDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (PAYMENTS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "payments"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("payments",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "payments"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != PAYMENTS_DSAR_CAPABILITY:
raise KeyError(name)
class PaymentsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = PaymentsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _obligation(
self,
row_id: str,
*,
tenant_id: str = "tenant-1",
payment_id: str,
payment_reference: str,
requested_by_ref: str,
subject: str,
) -> PaymentObligation:
return PaymentObligation(
id=row_id,
tenant_id=tenant_id,
payment_id=payment_id,
payment_reference=payment_reference,
source_module="cases",
source_resource_type="case",
source_resource_id=f"case-{row_id}",
amount_minor=12_500,
currency="EUR",
subject=subject,
status="paid",
idempotency_key=f"idempotency-{row_id}-do-not-export",
request_sha256="a" * 64,
requested_at=NOW,
requested_by_ref=requested_by_ref,
settled_at=NOW,
context_refs={
"service": "resident-permit",
"access_token": "payment-secret-do-not-export",
},
details={"private": "obligation-metadata-do-not-export"},
)
def _seed(self) -> None:
subject_payment = self._obligation(
"payment-row-1",
payment_id="payment-1",
payment_reference="PAY-0001",
requested_by_ref="account:account-1",
subject="Resident permit fee",
)
other_payment = self._obligation(
"payment-row-other",
payment_id="payment-other",
payment_reference="PAY-OTHER",
requested_by_ref="account:account-other",
subject="Other person's private payment",
)
other_tenant = self._obligation(
"payment-row-other-tenant",
tenant_id="tenant-2",
payment_id="payment-other-tenant",
payment_reference="PAY-TENANT-2",
requested_by_ref="account:account-1",
subject="Other tenant private payment",
)
self.session.add_all((subject_payment, other_payment, other_tenant))
self.session.flush()
self.session.add_all(
(
PaymentReconciliation(
id="reconciliation-1",
tenant_id="tenant-1",
reconciliation_id="reconciliation-command-1",
payment_row_id="payment-row-1",
mode="manual_full",
amount_minor=12_500,
currency="EUR",
transaction_reference="BANK-REFERENCE-1",
evidence_ref={
"kind": "record",
"owner_module": "records",
"evidence_id": "record-1",
"tenant_id": "tenant-1",
"version": "4",
"checksum": "b" * 64,
"source_ref": "records:record-1:v4",
"derived_from": ["bank-statement-1"],
"responsible_actor_ref": "account:account-1",
"captured_at": NOW.isoformat(),
"inspection_url": "/records/record-1",
},
idempotency_key="reconcile-key-do-not-export",
request_sha256="c" * 64,
received_at=NOW,
recorded_at=NOW,
recorded_by_ref="account:account-1",
details={"private": "reconciliation-metadata-do-not-export"},
),
PaymentEvent(
id="event-1",
tenant_id="tenant-1",
event_id="payment-event-1",
payment_row_id="payment-row-1",
event_type="payment.reconciled",
status="paid",
occurred_at=NOW,
actor_ref="account:account-1",
payload={"secret": "event-payload-do-not-export"},
),
PaymentEvent(
id="event-other",
tenant_id="tenant-1",
event_id="payment-event-other",
payment_row_id="payment-row-other",
event_type="payment.requested",
status="requested",
occurred_at=NOW,
actor_ref="account:account-other",
payload={"private": "other event"},
),
)
)
def test_exact_payment_reference_exports_bounded_financial_evidence(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"payments.reference": "PAY-0001"}
),
)
self.assertEqual(["payment-row-1"], [record.resource_id for record in records])
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Resident permit fee", exported)
self.assertIn("BANK-REFERENCE-1", exported)
self.assertIn("records:record-1:v4", exported)
self.assertIn("payment.reconciled", exported)
self.assertIn("[redacted]", exported)
for excluded in (
"payment-secret-do-not-export",
"obligation-metadata-do-not-export",
"reconciliation-metadata-do-not-export",
"event-payload-do-not-export",
"idempotency-payment-row-1-do-not-export",
"reconcile-key-do-not-export",
"inspection_url",
"Other person's private payment",
"Other tenant private payment",
):
self.assertNotIn(excluded, exported)
def test_actor_search_is_minimized_and_does_not_expose_subject(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(
{
"payment_request_attribution",
"payment_reconciliation_attribution",
"payment_event_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("PAY-0001", exported)
self.assertIn("recorded_payment_reconciliation", exported)
self.assertNotIn("Resident permit fee", exported)
self.assertNotIn("BANK-REFERENCE-1", exported)
self.assertNotIn("Other person's private payment", exported)
self.assertNotIn("Other tenant private payment", exported)
def test_identifiers_corroborate_and_alias_conflicts_fail_closed(self) -> None:
corroborated = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"payments.payment": "payment-1",
"payments.reference": "PAY-0001",
}
),
)
mismatched = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"payments.payment": "payment-1",
"payments.reference": "PAY-OTHER",
}
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"payments.account": "account-other"},
),
)
self.assertEqual(["payment-row-1"], [item.resource_id for item in corroborated])
self.assertEqual((), mismatched)
self.assertEqual((), conflict)
def test_erasure_is_retain_only_and_foreign_inputs_are_rejected(self) -> None:
subject = DsarSubjectRef(external_references={"payments.reference": "PAY-0001"})
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
self.assertTrue(all(not action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertIsNotNone(self.session.get(PaymentObligation, "payment-row-1"))
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="ledger",
module_id="ledger",
resource_type="payment_obligation",
resource_id="payment-row-1",
category="financial",
title="Foreign payment",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="ledger:retain:payment:payment-row-1",
provider_id="ledger",
module_id="ledger",
kind="retain",
resource_type="payment_obligation",
resource_id="payment-row-1",
title="Retain payment",
rationale="Financial evidence",
executable=False,
),
),
request_id="dsar-1",
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
subject = DsarSubjectRef(external_references={"payments.reference": "PAY-0001"})
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-PAYMENTS-1",
request_kind="access",
subject=subject,
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[PAYMENTS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
)
self.assertEqual(1, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-PAYMENTS-2",
request_kind="access",
subject=subject,
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[PAYMENTS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(PAYMENTS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(PAYMENTS_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
PAYMENTS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "payments.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_payments.backend.manifest import get_manifest
class PaymentsMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_payment_evidence_tables(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-payments-") as directory:
url = f"sqlite:///{Path(directory) / 'payments.db'}"
migrate_database(
database_url=url,
enabled_modules=("payments",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
self.assertTrue(
{
"payment_obligations",
"payment_reconciliations",
"payment_events",
}.issubset(set(inspect(engine).get_table_names()))
)
with engine.connect() as connection:
self.assertIn(
"e7b9c1d3f5a7",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import tomllib
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import EvidenceReference
from govoplan_core.core.payments import (
ManualPaymentReconciliationCommand,
PaymentRequestCommand,
)
from govoplan_payments.backend.db.models import (
PaymentEvent,
PaymentObligation,
PaymentReconciliation,
)
from govoplan_payments.backend.service import (
PaymentConflict,
PaymentError,
SqlPaymentRequestProvider,
)
from govoplan_payments.backend.manifest import manifest
NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC)
def request_command(**changes) -> PaymentRequestCommand:
values = {
"tenant_id": "tenant-1",
"source_module": "cases",
"source_resource_type": "case",
"source_resource_id": "case-rpp-1",
"amount_minor": 3000,
"currency": "EUR",
"subject": "Resident parking permit fee",
"idempotency_key": "case-rpp-1-payment",
"requested_at": NOW,
"requested_by_ref": "account:officer-1",
"due_at": NOW + timedelta(days=14),
"context_refs": {
"case": "case-rpp-1",
"workflow": "workflow-rpp-1",
},
}
values.update(changes)
return PaymentRequestCommand(**values)
def reconciliation_command(payment_id: str, **changes) -> ManualPaymentReconciliationCommand:
values = {
"tenant_id": "tenant-1",
"payment_id": payment_id,
"amount_minor": 3000,
"currency": "EUR",
"transaction_reference": "BANK-2026-0001",
"evidence_ref": EvidenceReference(
kind="document",
owner_module="files",
evidence_id="file-payment-1",
tenant_id="tenant-1",
version="1",
checksum="a" * 64,
),
"idempotency_key": "bank-2026-0001",
"received_at": NOW + timedelta(days=2),
"recorded_at": NOW + timedelta(days=2, minutes=5),
"recorded_by_ref": "account:cashier-1",
}
values.update(changes)
return ManualPaymentReconciliationCommand(**values)
class PaymentTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
PaymentObligation.__table__,
PaymentReconciliation.__table__,
PaymentEvent.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
self.provider = SqlPaymentRequestProvider()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_request_is_source_bound_and_replay_safe(self) -> None:
first = self.provider.request_payment(self.session, request_command())
replay = self.provider.request_payment(self.session, request_command())
self.assertEqual(first["payment_id"], replay["payment_id"])
self.assertTrue(replay["replayed"])
self.assertEqual("requested", first["status"])
self.assertEqual("case-rpp-1", first["source"]["resource_id"])
self.assertEqual(1, len(first["events"]))
with self.assertRaisesRegex(PaymentConflict, "idempotency conflict"):
self.provider.request_payment(
self.session,
request_command(amount_minor=3500),
)
self.assertIsNone(
self.provider.get_payment(
self.session,
tenant_id="tenant-2",
payment_id=str(first["payment_id"]),
)
)
def test_manual_reconciliation_requires_exact_immutable_evidence(self) -> None:
requested = self.provider.request_payment(self.session, request_command())
payment_id = str(requested["payment_id"])
with self.assertRaisesRegex(PaymentConflict, "amount and currency"):
self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(payment_id, amount_minor=2999),
)
with self.assertRaisesRegex(PaymentError, "another tenant"):
self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(
payment_id,
evidence_ref=EvidenceReference(
kind="document",
owner_module="files",
evidence_id="file-payment-1",
tenant_id="tenant-2",
version="1",
),
),
)
with self.assertRaisesRegex(PaymentError, "versioned or checksum"):
self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(
payment_id,
evidence_ref=EvidenceReference(
kind="document",
owner_module="files",
evidence_id="file-payment-1",
tenant_id="tenant-1",
),
),
)
paid = self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(payment_id),
)
replay = self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(payment_id),
)
self.assertEqual("paid", paid["status"])
self.assertEqual("BANK-2026-0001", paid["reconciliation"]["transaction_reference"])
self.assertEqual("file-payment-1", paid["reconciliation"]["evidence_ref"]["evidence_id"])
self.assertEqual(2, len(paid["events"]))
self.assertTrue(replay["replayed"])
with self.assertRaisesRegex(PaymentConflict, "already reconciled"):
self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(
payment_id,
idempotency_key="bank-2026-another",
transaction_reference="BANK-2026-ANOTHER",
),
)
def test_list_is_tenant_and_state_bounded(self) -> None:
first = self.provider.request_payment(self.session, request_command())
self.provider.reconcile_manual_payment(
self.session,
reconciliation_command(str(first["payment_id"])),
)
self.provider.request_payment(
self.session,
request_command(
source_resource_id="case-rpp-2",
idempotency_key="case-rpp-2-payment",
requested_at=NOW + timedelta(minutes=1),
),
)
paid = self.provider.list_payments(
self.session,
tenant_id="tenant-1",
status="paid",
)
requested = self.provider.list_payments(
self.session,
tenant_id="tenant-1",
status="requested",
)
self.assertEqual(1, len(paid))
self.assertEqual(1, len(requested))
self.assertEqual((), self.provider.list_payments(self.session, tenant_id="tenant-2"))
def test_manifest_exposes_permission_bounded_operator_workspace(self) -> None:
project = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text())
self.assertEqual(project["project"]["version"], manifest.version)
self.assertIsNotNone(manifest.frontend)
assert manifest.frontend is not None
self.assertEqual("@govoplan/payments-webui", manifest.frontend.package_name)
self.assertEqual("/payments", manifest.frontend.routes[0].path)
self.assertEqual(("payments:payment:read",), manifest.frontend.routes[0].required_any)
self.assertEqual("payments.workspace", manifest.frontend.routes[0].surface_id)
self.assertEqual("payments.navigation", manifest.frontend.nav_items[0].surface_id)
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
self.assertIn("payments.request.create", surface_ids)
self.assertIn("payments.reconciliation.manual", surface_ids)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/payments-webui",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/payments.css": "./src/styles/payments.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const read = (path) => readFileSync(resolve(root, path), "utf8");
const page = read("src/features/payments/PaymentsPage.tsx");
const createDialog = read("src/features/payments/PaymentRequestDialog.tsx");
const reconcileDialog = read("src/features/payments/ManualReconciliationDialog.tsx");
const styles = read("src/styles/payments.css");
assert.match(page, /<WorkspaceFrame/, "Payments uses the central full-height module frame");
assert.match(page, /<PageLayout/, "Payments uses the central headed page frame");
assert.match(page, /<PageActionBar[\s\S]*variant="collection"/, "Payments declares the collection action archetype");
assert.match(page, /reloadAction=/, "Payments provides the required reload slot");
assert.match(page, /createAction=/, "Payments provides the far-right create slot");
assert.match(page, /<MetricGrid/, "Payments summary geometry is centralized");
assert.match(page, /<FilterBar/, "Payments filters use the central bar");
assert.match(page, /<DataGrid/, "Payments rows use the central data grid");
assert.match(page, /<TableActionGroup/, "Payments keeps one stable ordered row action set");
assert.match(page, /disabledReason=/, "permission and state blockers remain actionable");
assert.match(page, /stale/, "refresh failures retain explicit stale-data state");
assert.match(createDialog, /<Dialog[\s\S]*<DialogForm/, "request creation composes central dialog anatomy");
assert.match(createDialog, /useUnsavedDraftGuard/, "request drafts use the shared discard guard");
assert.match(createDialog, /idempotency_key/, "request creation exposes replay protection");
assert.match(reconcileDialog, /<DescriptionList/, "reconciliation presents exact immutable facts semantically");
assert.match(reconcileDialog, /version[\s\S]*checksum/, "reconciliation captures immutable evidence binding");
assert.match(reconcileDialog, /useUnsavedDraftGuard/, "reconciliation drafts use the shared discard guard");
assert.doesNotMatch(styles, /\.page-heading|\.action-toolbar|\.dialog-panel|\.data-grid/, "Payments does not redefine shared page, toolbar, dialog, or table anatomy");
assert.doesNotMatch(`${page}\n${createDialog}\n${reconcileDialog}`, /window\.alert|\balert\s*\(/, "Payments does not use global alerts");
console.log("Payments interface-pattern contracts passed.");
+138
View File
@@ -0,0 +1,138 @@
import {
ApiError,
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type PaymentStatus = "requested" | "paid";
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
checksum?: string | null;
};
export type PaymentReconciliation = {
reconciliation_id: string;
mode: "manual";
amount_minor: number;
currency: string;
transaction_reference: string;
evidence_ref: EvidenceReference;
received_at: string;
recorded_at: string;
recorded_by_ref: string;
};
export type PaymentEvent = {
event_id: string;
event_type: string;
status: PaymentStatus;
occurred_at: string;
actor_ref: string;
payload: Record<string, unknown>;
};
export type PaymentRequest = {
payment_id: string;
tenant_id: string;
payment_reference: string;
source: {
module: string;
resource_type: string;
resource_id: string;
};
amount_minor: number;
currency: string;
subject: string;
status: PaymentStatus;
requested_at: string;
requested_by_ref: string;
due_at?: string | null;
settled_at?: string | null;
context_refs: Record<string, string>;
metadata: Record<string, unknown>;
reconciliation?: PaymentReconciliation | null;
events: PaymentEvent[];
replayed: boolean;
};
export type PaymentRequestCreate = {
source_module: string;
source_resource_type: string;
source_resource_id: string;
amount_minor: number;
currency: string;
subject: string;
idempotency_key: string;
due_at?: string | null;
context_refs: Record<string, string>;
metadata: Record<string, unknown>;
};
export type ManualPaymentReconciliationCreate = {
amount_minor: number;
currency: string;
transaction_reference: string;
evidence_ref: EvidenceReference;
idempotency_key: string;
received_at: string;
metadata: Record<string, unknown>;
};
export async function listPaymentRequests(
settings: ApiSettings,
filters: { status?: PaymentStatus; sourceResourceId?: string; limit?: number } = {},
signal?: AbortSignal
): Promise<PaymentRequest[]> {
const response = await apiFetch<{ payments: PaymentRequest[] }>(
settings,
apiPath("/api/v1/payments/requests", {
status: filters.status,
source_resource_id: filters.sourceResourceId,
limit: filters.limit ?? 200
}),
{ signal }
);
return response.payments;
}
export function createPaymentRequest(
settings: ApiSettings,
payload: PaymentRequestCreate
): Promise<PaymentRequest> {
return apiFetch<PaymentRequest>(settings, "/api/v1/payments/requests", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function reconcileManualPayment(
settings: ApiSettings,
paymentId: string,
payload: ManualPaymentReconciliationCreate
): Promise<PaymentRequest> {
return apiFetch<PaymentRequest>(
settings,
`/api/v1/payments/requests/${encodeURIComponent(paymentId)}/manual-reconciliations`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function paymentApiErrorMessage(reason: unknown): string {
if (reason instanceof ApiError) {
try {
const payload = JSON.parse(reason.body) as { detail?: unknown };
if (typeof payload.detail === "string") return payload.detail;
} catch {
// The response body may be plain text.
}
if (reason.status === 409) return "The payment changed or this replay key is already bound to different evidence. Reload and review the current state.";
if (reason.status === 403) return "Your current role does not permit this payment action.";
}
return reason instanceof Error ? reason.message : String(reason);
}
@@ -0,0 +1,249 @@
import { useEffect, useState, type FormEvent } from "react";
import {
Button,
DateTimeField,
DescriptionItem,
DescriptionList,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
paymentApiErrorMessage,
reconcileManualPayment,
type PaymentRequest
} from "../../api/payments";
type ManualReconciliationDialogProps = {
open: boolean;
settings: ApiSettings;
tenantId: string;
payment: PaymentRequest | null;
onClose: () => void;
onReconciled: (payment: PaymentRequest) => void;
};
type ReconciliationDraft = {
transactionReference: string;
receivedAt: string;
evidenceOwnerModule: string;
evidenceKind: string;
evidenceId: string;
evidenceVersion: string;
evidenceChecksum: string;
idempotencyKey: string;
};
const FORM_ID = "payments-manual-reconciliation-form";
function localDateTime(date = new Date()): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function replayKey(): string {
const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `payments-ui-reconciliation-${suffix}`;
}
function emptyDraft(): ReconciliationDraft {
return {
transactionReference: "",
receivedAt: localDateTime(),
evidenceOwnerModule: "files",
evidenceKind: "document",
evidenceId: "",
evidenceVersion: "",
evidenceChecksum: "",
idempotencyKey: replayKey()
};
}
function formatAmount(amountMinor: number, currency: string): string {
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amountMinor / 100);
} catch {
return `${(amountMinor / 100).toFixed(2)} ${currency}`;
}
}
export default function ManualReconciliationDialog({
open,
settings,
tenantId,
payment,
onClose,
onReconciled
}: ManualReconciliationDialogProps) {
const [draft, setDraft] = useState<ReconciliationDraft>(emptyDraft);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
function reset() {
setDraft(emptyDraft());
setDirty(false);
setError("");
}
useEffect(() => {
if (open) reset();
}, [open, payment?.payment_id]);
function change<K extends keyof ReconciliationDraft>(key: K, value: ReconciliationDraft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
setDirty(true);
}
async function submit(): Promise<boolean> {
if (!payment) return false;
if (!draft.transactionReference.trim() || !draft.evidenceOwnerModule.trim() || !draft.evidenceKind.trim() || !draft.evidenceId.trim()) {
setError("Transaction reference and evidence owner, kind, and ID are required.");
return false;
}
if (!draft.evidenceVersion.trim() && !draft.evidenceChecksum.trim()) {
setError("Provide an evidence version or checksum so the receipt evidence is immutable.");
return false;
}
if (!draft.receivedAt) {
setError("Payment received time is required.");
return false;
}
setBusy(true);
setError("");
try {
const reconciled = await reconcileManualPayment(settings, payment.payment_id, {
amount_minor: payment.amount_minor,
currency: payment.currency,
transaction_reference: draft.transactionReference.trim(),
evidence_ref: {
tenant_id: tenantId,
owner_module: draft.evidenceOwnerModule.trim(),
kind: draft.evidenceKind.trim(),
evidence_id: draft.evidenceId.trim(),
version: draft.evidenceVersion.trim() || null,
checksum: draft.evidenceChecksum.trim() || null
},
idempotency_key: draft.idempotencyKey.trim(),
received_at: new Date(draft.receivedAt).toISOString(),
metadata: {}
});
setDirty(false);
onReconciled(reconciled);
return true;
} catch (reason) {
setError(paymentApiErrorMessage(reason));
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
title: "Discard the reconciliation draft?",
message: "No payment state has changed yet. Save the exact receipt evidence before leaving or discard this draft.",
onSave: submit,
onDiscard: reset,
enabled: open
});
function close() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
void submit();
}
return (
<Dialog
open={open}
title="Record manual payment"
description="Confirm a full offline receipt against immutable evidence. Payments rejects any amount or currency mismatch."
size="wide"
closeDisabled={busy}
onClose={close}
interfaceId="payments.reconciliation.manual.dialog"
helpContextId="payments.reconciliation.manual"
helpModuleId="payments"
notices={error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
footer={(
<>
<Button type="button" onClick={close} disabled={busy}>Cancel</Button>
<Button type="submit" form={FORM_ID} variant="primary" disabled={busy || !payment}>
{busy ? "Recording…" : "Record payment as paid"}
</Button>
</>
)}
>
<DialogForm id={FORM_ID} onSubmit={handleSubmit}>
{payment && (
<DialogSection variant="inset" className="payments-reconciliation-warning">
<h3 className="payments-dialog-section-title">Exact obligation</h3>
<DescriptionList columns={2} density="compact">
<DescriptionItem term="Payment reference">{payment.payment_reference}</DescriptionItem>
<DescriptionItem term="Amount"><span className="payments-readonly-amount">{formatAmount(payment.amount_minor, payment.currency)}</span></DescriptionItem>
<DescriptionItem term="Source">{payment.source.module}:{payment.source.resource_type}:{payment.source.resource_id}</DescriptionItem>
<DescriptionItem term="Current state">Requested</DescriptionItem>
</DescriptionList>
<p className="payments-dialog-copy">This action appends reconciliation evidence and marks the obligation paid. It cannot be silently undone; correction or reversal requires a future governed adjustment flow.</p>
</DialogSection>
)}
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Receipt</h3>
<FormGrid columns={2}>
<FormField label="External transaction reference" helpContextId="payments.reconciliation.field.transaction-reference" helpModuleId="payments">
<input required maxLength={255} value={draft.transactionReference} onChange={(event) => change("transactionReference", event.target.value)} />
</FormField>
<FormField label="Payment received date and time">
<DateTimeField required value={draft.receivedAt} onChange={(value) => change("receivedAt", value)} aria-label="Payment received date and time" />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Immutable evidence</h3>
<p className="payments-dialog-section-copy">Payments stores only this typed reference. The evidence bytes and retention remain with the owning module.</p>
<FormGrid columns={2}>
<FormField label="Evidence owner module">
<input required maxLength={120} value={draft.evidenceOwnerModule} onChange={(event) => change("evidenceOwnerModule", event.target.value)} />
</FormField>
<FormField label="Evidence kind">
<input required maxLength={120} value={draft.evidenceKind} onChange={(event) => change("evidenceKind", event.target.value)} />
</FormField>
<FormField label="Evidence ID">
<input required maxLength={255} value={draft.evidenceId} onChange={(event) => change("evidenceId", event.target.value)} />
</FormField>
<FormField label="Evidence version" help="Provide a version or checksum; both may be supplied.">
<input maxLength={255} value={draft.evidenceVersion} onChange={(event) => change("evidenceVersion", event.target.value)} />
</FormField>
<FormField label="Evidence checksum" help="Provide a checksum or version; both may be supplied.">
<input maxLength={255} value={draft.evidenceChecksum} onChange={(event) => change("evidenceChecksum", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<h3 className="payments-dialog-section-title">Replay protection</h3>
<p className="payments-dialog-section-copy">Retry this key only for this exact payment and evidence. A changed replay conflicts instead of creating ambiguous settlement evidence.</p>
<FormField label="Idempotency key" helpContextId="payments.reconciliation.field.replay-key" helpModuleId="payments">
<input required maxLength={255} value={draft.idempotencyKey} onChange={(event) => change("idempotencyKey", event.target.value)} />
</FormField>
</DialogSection>
</DialogForm>
</Dialog>
);
}
@@ -0,0 +1,240 @@
import { useEffect, useState, type FormEvent } from "react";
import {
Button,
DateTimeField,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
createPaymentRequest,
paymentApiErrorMessage,
type PaymentRequest,
type PaymentRequestCreate
} from "../../api/payments";
type PaymentRequestDialogProps = {
open: boolean;
settings: ApiSettings;
onClose: () => void;
onCreated: (payment: PaymentRequest) => void;
};
type PaymentRequestDraft = {
sourceModule: string;
sourceResourceType: string;
sourceResourceId: string;
subject: string;
amount: string;
currency: string;
dueAt: string;
caseRef: string;
workflowRef: string;
idempotencyKey: string;
};
const FORM_ID = "payments-create-request-form";
function replayKey(prefix: string): string {
const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${suffix}`;
}
function emptyDraft(): PaymentRequestDraft {
return {
sourceModule: "cases",
sourceResourceType: "case",
sourceResourceId: "",
subject: "",
amount: "",
currency: "EUR",
dueAt: "",
caseRef: "",
workflowRef: "",
idempotencyKey: replayKey("payments-ui-request")
};
}
function amountToMinor(value: string): number | null {
const normalized = value.trim().replace(",", ".");
if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return null;
const [whole, fraction = ""] = normalized.split(".");
const result = Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
return Number.isSafeInteger(result) && result > 0 ? result : null;
}
export default function PaymentRequestDialog({ open, settings, onClose, onCreated }: PaymentRequestDialogProps) {
const [draft, setDraft] = useState<PaymentRequestDraft>(emptyDraft);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
function reset() {
setDraft(emptyDraft());
setDirty(false);
setError("");
}
useEffect(() => {
if (open) reset();
}, [open]);
function change<K extends keyof PaymentRequestDraft>(key: K, value: PaymentRequestDraft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
setDirty(true);
}
async function submit(): Promise<boolean> {
const amountMinor = amountToMinor(draft.amount);
if (!draft.sourceModule.trim() || !draft.sourceResourceType.trim() || !draft.sourceResourceId.trim() || !draft.subject.trim()) {
setError("Source, source ID, and payment subject are required.");
return false;
}
if (amountMinor === null) {
setError("Enter a positive amount with no more than two decimal places.");
return false;
}
if (!/^[A-Za-z]{3}$/.test(draft.currency.trim())) {
setError("Currency must be a three-letter ISO code.");
return false;
}
const contextRefs = Object.fromEntries([
["case", draft.caseRef.trim()],
["workflow", draft.workflowRef.trim()]
].filter((entry): entry is [string, string] => Boolean(entry[1])));
const payload: PaymentRequestCreate = {
source_module: draft.sourceModule.trim(),
source_resource_type: draft.sourceResourceType.trim(),
source_resource_id: draft.sourceResourceId.trim(),
amount_minor: amountMinor,
currency: draft.currency.trim().toUpperCase(),
subject: draft.subject.trim(),
idempotency_key: draft.idempotencyKey.trim(),
due_at: draft.dueAt ? new Date(draft.dueAt).toISOString() : null,
context_refs: contextRefs,
metadata: {}
};
setBusy(true);
setError("");
try {
const payment = await createPaymentRequest(settings, payload);
setDirty(false);
onCreated(payment);
return true;
} catch (reason) {
setError(paymentApiErrorMessage(reason));
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
title: "Discard the payment request draft?",
message: "The payment request has not been created. Save it before leaving or discard the draft.",
onSave: submit,
onDiscard: reset,
enabled: open
});
function close() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
void submit();
}
return (
<Dialog
open={open}
title="Create payment request"
description="Create one fixed, source-bound obligation. The returned payment reference remains stable for the owning procedure."
size="wide"
closeDisabled={busy}
onClose={close}
interfaceId="payments.request.create.dialog"
helpContextId="payments.request.create"
helpModuleId="payments"
notices={error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
footer={(
<>
<Button type="button" onClick={close} disabled={busy}>Cancel</Button>
<Button type="submit" form={FORM_ID} variant="primary" disabled={busy}>
{busy ? "Creating…" : "Create request"}
</Button>
</>
)}
>
<DialogForm id={FORM_ID} onSubmit={handleSubmit}>
<DialogSection>
<h3 className="payments-dialog-section-title">Owning source</h3>
<p className="payments-dialog-section-copy">Use the stable reference of the Case, Workflow, or other procedure that owns this obligation.</p>
<FormGrid columns={2}>
<FormField label="Source module" helpContextId="payments.request.field.source-module" helpModuleId="payments">
<input required maxLength={120} value={draft.sourceModule} onChange={(event) => change("sourceModule", event.target.value)} />
</FormField>
<FormField label="Resource type" helpContextId="payments.request.field.resource-type" helpModuleId="payments">
<input required maxLength={120} value={draft.sourceResourceType} onChange={(event) => change("sourceResourceType", event.target.value)} />
</FormField>
<FormField label="Source resource ID" helpContextId="payments.request.field.source-id" helpModuleId="payments">
<input required maxLength={255} value={draft.sourceResourceId} onChange={(event) => change("sourceResourceId", event.target.value)} />
</FormField>
<FormField label="Payment subject" helpContextId="payments.request.field.subject" helpModuleId="payments">
<input required maxLength={1000} value={draft.subject} onChange={(event) => change("subject", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Obligation</h3>
<FormGrid columns={2}>
<FormField label="Amount" help="Enter the major currency amount, for example 30.00.">
<input required inputMode="decimal" placeholder="0.00" value={draft.amount} onChange={(event) => change("amount", event.target.value)} />
</FormField>
<FormField label="Currency" help="Three-letter ISO currency code.">
<input required maxLength={3} value={draft.currency} onChange={(event) => change("currency", event.target.value.toUpperCase())} />
</FormField>
<FormField label="Due date and time" help="Optional. The local time is converted to an absolute timestamp.">
<DateTimeField value={draft.dueAt} onChange={(value) => change("dueAt", value)} aria-label="Payment due date and time" />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Procedure context</h3>
<p className="payments-dialog-section-copy">Optional references make the source visible without copying applicant or form data into Payments.</p>
<FormGrid columns={2}>
<FormField label="Case reference">
<input maxLength={255} value={draft.caseRef} onChange={(event) => change("caseRef", event.target.value)} />
</FormField>
<FormField label="Workflow reference">
<input maxLength={255} value={draft.workflowRef} onChange={(event) => change("workflowRef", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<h3 className="payments-dialog-section-title">Replay protection</h3>
<p className="payments-dialog-section-copy">Retry with this key only for the same source, amount, currency, subject, dates, and context. Reusing it for changed values is rejected.</p>
<FormField label="Idempotency key" helpContextId="payments.request.field.replay-key" helpModuleId="payments">
<input required maxLength={255} value={draft.idempotencyKey} onChange={(event) => change("idempotencyKey", event.target.value)} />
</FormField>
</DialogSection>
</DialogForm>
</Dialog>
);
}
@@ -0,0 +1,358 @@
import { CheckCircle2, Plus } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Card,
DataGrid,
DismissibleAlert,
DocumentationHelpLink,
FilterBar,
MetricCard,
MetricGrid,
PageActionBar,
PageLayout,
StatePanel,
StatusBadge,
TableActionGroup,
WorkspaceFrame,
hasScope,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listPaymentRequests,
paymentApiErrorMessage,
type PaymentRequest,
type PaymentStatus
} from "../../api/payments";
import ManualReconciliationDialog from "./ManualReconciliationDialog";
import PaymentRequestDialog from "./PaymentRequestDialog";
function formatAmount(amountMinor: number, currency: string): string {
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amountMinor / 100);
} catch {
return `${(amountMinor / 100).toFixed(2)} ${currency}`;
}
}
function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function reconciliationEvidence(payment: PaymentRequest): string {
const evidence = payment.reconciliation?.evidence_ref;
if (!evidence) return "Not recorded";
const immutableRef = evidence.version ? `version ${evidence.version}` : `checksum ${String(evidence.checksum).slice(0, 12)}`;
return `${evidence.owner_module}:${evidence.evidence_id} · ${immutableRef}`;
}
export default function PaymentsPage({ settings, auth }: PlatformRouteContext) {
const [payments, setPayments] = useState<PaymentRequest[]>([]);
const [statusFilter, setStatusFilter] = useState<"all" | PaymentStatus>("all");
const [sourceFilter, setSourceFilter] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [initialError, setInitialError] = useState("");
const [staleError, setStaleError] = useState("");
const [success, setSuccess] = useState("");
const [loadedAt, setLoadedAt] = useState<Date | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [reconcilingPayment, setReconcilingPayment] = useState<PaymentRequest | null>(null);
const loadedRef = useRef(false);
const canRead = hasScope(auth, "payments:payment:read");
const canCreate = hasScope(auth, "payments:payment:write");
const canReconcile = hasScope(auth, "payments:payment:reconcile");
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
async function reload(signal?: AbortSignal) {
if (!canRead) {
setLoading(false);
return;
}
if (loadedRef.current) setRefreshing(true);
else setLoading(true);
setInitialError("");
try {
const result = await listPaymentRequests(settings, {}, signal);
setPayments(result);
setLoadedAt(new Date());
setStaleError("");
loadedRef.current = true;
} catch (reason) {
if (reason instanceof Error && reason.name === "AbortError") return;
const message = paymentApiErrorMessage(reason);
if (loadedRef.current) setStaleError(message);
else setInitialError(message);
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
loadedRef.current = false;
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings, canRead]);
const filteredPayments = useMemo(() => {
const sourceQuery = sourceFilter.trim().toLocaleLowerCase();
return payments.filter((payment) => {
if (statusFilter !== "all" && payment.status !== statusFilter) return false;
if (!sourceQuery) return true;
return [
payment.source.module,
payment.source.resource_type,
payment.source.resource_id,
payment.context_refs.case,
payment.context_refs.workflow,
payment.payment_reference,
payment.subject
].filter(Boolean).join(" ").toLocaleLowerCase().includes(sourceQuery);
});
}, [payments, sourceFilter, statusFilter]);
const requestedCount = payments.filter((payment) => payment.status === "requested").length;
const paidCount = payments.filter((payment) => payment.status === "paid").length;
const overdueCount = payments.filter((payment) => payment.status === "requested" && payment.due_at && new Date(payment.due_at) < new Date()).length;
const columns = useMemo<DataGridColumn<PaymentRequest>[]>(() => [
{
id: "reference",
header: "Payment",
width: "1.2fr",
minWidth: 220,
sortable: true,
filterable: true,
value: (payment) => `${payment.payment_reference} ${payment.subject}`,
render: (payment) => <div className="payments-source"><strong>{payment.subject}</strong><span>{payment.payment_reference}</span></div>
},
{
id: "source",
header: "Owning source",
width: "1.1fr",
minWidth: 210,
sortable: true,
filterable: true,
value: (payment) => `${payment.source.module} ${payment.source.resource_type} ${payment.source.resource_id} ${payment.context_refs.case ?? ""} ${payment.context_refs.workflow ?? ""}`,
render: (payment) => (
<div className="payments-source">
<strong>{payment.source.module}:{payment.source.resource_type}</strong>
<span>{payment.source.resource_id}</span>
{(payment.context_refs.case || payment.context_refs.workflow) && <span>{[payment.context_refs.case && `Case ${payment.context_refs.case}`, payment.context_refs.workflow && `Workflow ${payment.context_refs.workflow}`].filter(Boolean).join(" · ")}</span>}
</div>
)
},
{
id: "amount",
header: "Amount",
width: 130,
minWidth: 120,
align: "right",
sortable: true,
sortValue: (payment) => payment.amount_minor,
render: (payment) => <strong className="payments-amount">{formatAmount(payment.amount_minor, payment.currency)}</strong>
},
{
id: "status",
header: "State",
width: 115,
minWidth: 105,
sortable: true,
filterable: true,
filterType: "list",
value: (payment) => payment.status,
list: {
options: [
{ value: "requested", label: "Requested" },
{ value: "paid", label: "Paid" }
],
display: "pill"
},
render: (payment) => <StatusBadge status={payment.status === "paid" ? "active" : "pending"} label={payment.status === "paid" ? "Paid" : "Requested"} />
},
{
id: "dates",
header: "Due / settled",
width: 190,
minWidth: 170,
sortable: true,
sortValue: (payment) => payment.settled_at ?? payment.due_at ?? payment.requested_at,
render: (payment) => <div className="payments-dates"><strong>{payment.status === "paid" ? `Settled ${formatDateTime(payment.settled_at)}` : `Due ${formatDateTime(payment.due_at)}`}</strong><span>Requested {formatDateTime(payment.requested_at)}</span></div>
},
{
id: "evidence",
header: "Reconciliation evidence",
width: "1fr",
minWidth: 210,
filterable: true,
value: reconciliationEvidence,
render: (payment) => (
<div className="payments-evidence">
<strong>{payment.reconciliation?.transaction_reference ?? "Not reconciled"}</strong>
<span>{reconciliationEvidence(payment)}</span>
</div>
)
},
{
id: "actions",
header: "Actions",
width: 88,
minWidth: 88,
sticky: "end",
align: "right",
resizable: false,
render: (payment) => (
<TableActionGroup
label={`Actions for ${payment.payment_reference}`}
actions={[
{
id: "reconcile",
label: "Record manual payment",
icon: <CheckCircle2 size={16} />,
onClick: () => setReconcilingPayment(payment),
disabled: payment.status === "paid" || !canReconcile,
disabledReason: payment.status === "paid"
? "This payment is already reconciled. A correction requires a governed adjustment flow."
: !canReconcile
? "The payments:payment:reconcile permission is required. Ask a Payments administrator to grant a reconciliation role."
: undefined
}
]}
/>
)
}
], [canReconcile]);
function handleCreated(payment: PaymentRequest) {
setPayments((current) => [payment, ...current.filter((item) => item.payment_id !== payment.payment_id)]);
setCreateOpen(false);
setSuccess(payment.replayed
? `Payment request ${payment.payment_reference} was returned from the existing replay key.`
: `Payment request ${payment.payment_reference} was created.`);
}
function handleReconciled(payment: PaymentRequest) {
setPayments((current) => current.map((item) => item.payment_id === payment.payment_id ? payment : item));
setReconcilingPayment(null);
setSuccess(payment.replayed
? `Existing reconciliation for ${payment.payment_reference} was returned from the replay key.`
: `Payment ${payment.payment_reference} was recorded as paid with immutable evidence.`);
}
const createButton = (
<Button
variant="primary"
onClick={() => setCreateOpen(true)}
disabled={!canCreate}
disabledReason={!canCreate ? "The payments:payment:write permission is required. Ask a Payments administrator to grant a payment operator role." : undefined}
interfaceId="payments.request.create"
helpContextId="payments.request.create"
helpModuleId="payments"
>
<Plus size={16} aria-hidden="true" /> Create request
</Button>
);
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" label="Payments workspace" interfaceId="payments.workspace" helpContextId="payments.workspace" helpModuleId="payments">
<PageLayout
archetype="collection"
mode="standalone"
title="Payment requests" titleHelp={<DocumentationHelpLink reference={{ topicId: "payments.requests-and-reconciliation", documentationType: "user" }} label="Open Payments documentation" />}
description="Track source-bound obligations and record exact manual receipts against immutable evidence."
loading={loading}
loadingLabel="Loading payment requests"
success={success}
interfaceId="payments.workspace.page"
helpContextId="payments.workspace"
helpModuleId="payments"
actions={(
<PageActionBar
variant="collection"
refreshable
label="Payment request actions"
interfaceId="payments.workspace.actions"
helpContextId="payments.workspace"
helpModuleId="payments"
reloadAction={{ onReload: () => void reload(), loading: refreshing, label: "Reload payment requests" }}
createAction={createButton}
/>
)}
notices={staleError ? (
<DismissibleAlert tone="warning" resetKey={staleError}>
<div>The loaded payment list may be stale because refresh failed: {staleError}</div>
<div className="payments-notice-action"><Button type="button" onClick={() => void reload()}>Retry reload</Button></div>
</DismissibleAlert>
) : null}
>
{!canRead ? (
<StatePanel
size="fill"
tone="warning"
title="Payment access is unavailable"
description="The payments:payment:read permission is required. Ask a Payments administrator to grant a payment reader, operator, or auditor role."
/>
) : initialError ? (
<StatePanel
size="fill"
tone="danger"
title="Payment requests could not be loaded"
description={initialError}
actions={<Button type="button" onClick={() => void reload()}>Retry</Button>}
/>
) : (
<>
<MetricGrid columns={4} density="compact" spacing="none" minimum="compact" collapseAt="standard">
<MetricCard density="compact" label="All requests" value={payments.length} detail={loadedAt ? `Updated ${loadedAt.toLocaleTimeString()}` : "Not loaded"} />
<MetricCard density="compact" tone="warning" label="Requested" value={requestedCount} detail="Awaiting receipt" />
<MetricCard density="compact" tone="good" label="Paid" value={paidCount} detail="Evidence recorded" />
<MetricCard density="compact" tone={overdueCount ? "danger" : "neutral"} label="Overdue" value={overdueCount} detail="Requested past due time" />
</MetricGrid>
<FilterBar surface="panel" className="payments-filter-bar">
<select aria-label="Filter payment state" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "all" | PaymentStatus)}>
<option value="all">All states</option>
<option value="requested">Requested</option>
<option value="paid">Paid</option>
</select>
<input aria-label="Filter by source or payment reference" placeholder="Source, Case, Workflow, or payment reference" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} />
{(statusFilter !== "all" || sourceFilter) && <Button type="button" variant="ghost" onClick={() => { setStatusFilter("all"); setSourceFilter(""); }}>Clear filters</Button>}
</FilterBar>
<Card title={`${filteredPayments.length} payment request${filteredPayments.length === 1 ? "" : "s"}`} interfaceId="payments.requests.list" helpContextId="payments.workspace.list" helpModuleId="payments">
<DataGrid
id="payments.requests"
storageKey="govoplan.payments.requests.grid"
rows={filteredPayments}
columns={columns}
getRowKey={(payment) => payment.payment_id}
initialSort={{ columnId: "dates", direction: "desc" }}
emptyText="No payment requests have been created."
filteredEmptyText="No payment requests match the current filters."
emptyAction={createButton}
emptyActionColumnId="actions"
/>
</Card>
</>
)}
</PageLayout>
<PaymentRequestDialog open={createOpen} settings={settings} onClose={() => setCreateOpen(false)} onCreated={handleCreated} />
<ManualReconciliationDialog
open={Boolean(reconcilingPayment)}
settings={settings}
tenantId={tenantId}
payment={reconcilingPayment}
onClose={() => setReconcilingPayment(null)}
onReconciled={handleReconciled}
/>
</WorkspaceFrame>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default, paymentsModule } from "./module";
export * from "./api/payments";
+50
View File
@@ -0,0 +1,50 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/payments.css";
const PaymentsPage = lazy(() => import("./features/payments/PaymentsPage"));
export const paymentsModule: PlatformWebModule = {
id: "payments",
label: "Payments",
version: "0.1.20",
optionalDependencies: ["files", "audit", "cases", "workflow_engine", "ledger", "xrechnung"],
routes: [
{
path: "/payments",
anyOf: ["payments:payment:read"],
order: 73,
surfaceId: "payments.workspace",
render: (context) => createElement(PaymentsPage, context)
}
],
navItems: [
{
to: "/payments",
label: "Payments",
iconName: "landmark",
anyOf: ["payments:payment:read"],
order: 73,
surfaceId: "payments.navigation"
}
],
productAreas: [
{
id: "services-cases",
moduleId: "payments",
label: "i18n:govoplan-core.product_area.services_cases",
description: "i18n:govoplan-core.product_area.services_cases_description",
iconName: "landmark",
surfaceIds: ["payments.navigation", "payments.workspace"],
order: 20
}
],
viewSurfaces: [
{ id: "payments.navigation", moduleId: "payments", kind: "navigation", label: "Payments navigation", order: 10 },
{ id: "payments.workspace", moduleId: "payments", kind: "route", label: "Payment request workspace", order: 20 },
{ id: "payments.request.create", moduleId: "payments", kind: "section", label: "Create payment request", parentId: "payments.workspace", order: 30 },
{ id: "payments.reconciliation.manual", moduleId: "payments", kind: "section", label: "Record manual payment", parentId: "payments.workspace", order: 40 }
]
};
export default paymentsModule;
+19
View File
@@ -0,0 +1,19 @@
.payments-page .page-layout-body { display: grid; gap: 18px; }
.payments-filter-bar { justify-content: flex-start; }
.payments-filter-bar input { min-width: min(320px, 100%); }
.payments-amount { font-variant-numeric: tabular-nums; white-space: nowrap; }
.payments-source { min-width: 0; display: grid; gap: 2px; }
.payments-source span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.payments-dates { display: grid; gap: 3px; font-size: 12px; }
.payments-dates span { color: var(--muted); }
.payments-evidence { min-width: 0; display: grid; gap: 2px; font-size: 12px; overflow-wrap: anywhere; }
.payments-notice-action { margin-top: 8px; }
.payments-dialog-copy { margin: 0; color: var(--muted); line-height: 1.5; }
.payments-dialog-section-title { margin: 0 0 8px; color: var(--text-strong); font-size: 14px; }
.payments-dialog-section-copy { margin: 0 0 12px; color: var(--muted); line-height: 1.5; }
.payments-readonly-amount { color: var(--text-strong); font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.payments-reconciliation-warning { border-left: 3px solid var(--amber); }
@media (max-width: 760px) {
.payments-filter-bar input,
.payments-filter-bar select { width: 100%; min-width: 0; }
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/*": ["../../govoplan-core/webui/node_modules/@types/react/*"]
}
},
"include": ["src", "../../govoplan-core/webui/src/vite-env.d.ts"]
}