feat: add governed payment request slice

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent 6b085cd1b1
commit 9c8fdba7f5
17 changed files with 1928 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
+22 -1
View File
@@ -1,5 +1,26 @@
# govoplan-payments
# GovOPlaN Payments
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- 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.
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
```
+67
View File
@@ -0,0 +1,67 @@
# 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.
## 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.
+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.19"
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.18"]
[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.19"
@@ -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"]
+236
View File
@@ -0,0 +1,236 @@
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,
DocumentationLink,
DocumentationTopic,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.payments import CAPABILITY_PAYMENT_REQUESTS
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base
from govoplan_payments.backend.db import models as payment_models
from govoplan_payments.backend.service import SqlPaymentRequestProvider
MODULE_ID = "payments"
MODULE_NAME = "Payments"
MODULE_VERSION = "0.1.19"
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 _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,),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_PAYMENT_REQUESTS, version="1.0.0"),
),
capability_factories={CAPABILITY_PAYMENT_REQUESTS: _payment_requests},
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",
),
},
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.requests-and-reconciliation",
title="Payment requests and manual reconciliation",
summary="Create an exact obligation and mark it paid only with matching, immutable evidence.",
body=(
"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 first supported receipt path is manual reconciliation of a full payment. The operator must record the exact amount and currency, external transaction reference, received time, and a same-tenant EvidenceReference carrying a version or checksum. 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"),
links=(
DocumentationLink(
label="Payments boundary and recovery",
href="govoplan-payments/docs/PAYMENTS_DOMAIN.md",
kind="repository",
),
),
metadata={
"help_contexts": [
"payments.request",
"payments.reconciliation.manual",
"payments.state.requested",
"payments.state.paid",
],
"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.",
},
},
),
),
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, applicant payment page, or dedicated operator WebUI is included yet.",
),
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 @@
+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()
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
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,
)
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"))
if __name__ == "__main__":
unittest.main()