diff --git a/schemas/README.md b/schemas/README.md index 0a97c20..3d47dae 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -6,11 +6,23 @@ Versionierte JSON-Schemas für die kanonischen Business-, Datenquellen- und Senk ## Vorhandene Schemas -| Pfad | Rolle | Verwendet in | -| ----------------------------------------------- | ------------------------------ | -------------------------------------------------- | -| `expense/canonical/v1.0.0.json` | Kanonische Business-Form | Approval-Attestierung (`subject`, `materials`), Agent-A-Transform-Output | -| `ds/expense/project_assignment/v1.0.0.json` | Datenquellen-Rückgabe | Query Attestation von `ds:expense-db-app` / `project_assignments` | -| `sink/db/expense/v1.0.0.json` | Senken-Payload | Transform-Attestierung `expense.map_to_sink` → `sink:expense-db-app` | +| Pfad | Rolle | Status | Verwendet in | +| ----------------------------------------------- | ------------------------------ | --------- | -------------------------------------------------- | +| `expense/canonical/v1.0.0.json` | Kanonische Business-Form | legacy | Approval-Attestierung (`subject`, `materials`), Agent-A-Transform-Output | +| `expense/canonical/v1.1.0.json` | Kanonische Business-Form | **aktiv** | wie v1.0.0, zusätzlich mit Zeilen-Allokationen (Multi-Projekt pro Item) | +| `ds/expense/project_assignment/v1.0.0.json` | Datenquellen-Rückgabe | aktiv | Query Attestation von `ds:expense-db-app` / `project_assignments` | +| `sink/db/expense/v1.0.0.json` | Senken-Payload | legacy | Transform-Attestierung `expense.map_to_sink` → `sink:expense-db-app` (ohne Splits) | +| `sink/db/expense/v1.1.0.json` | Senken-Payload | **aktiv** | wie v1.0.0, zusätzlich mit per-Item-Allocation-Rows für die DB-App | + +Neue Workflows SOLLEN die v1.1.0-Fassungen nutzen. v1.0.0 bleibt parallel bestehen, damit Attestierungen aus deren Laufzeit verifizierbar bleiben. + +## Zeilen-Allokationen (v1.1.0) + +Jedes `expense_items[]` darf ein optionales `allocations[]`-Array tragen. Jede Allocation referenziert ein `project_code`+`cost_center`+`source_attestation_ref` und trägt entweder einen Prozentsatz (`mode: "percentage"`, Feld `percentage`) oder einen absoluten Betrag (`mode: "absolute"`, Feld `amount`). Alle Entries desselben Items müssen denselben Modus haben. OPA erzwingt Summenerhalt: `sum(percentage) == 100.00` bzw. `sum(amount) == item.amount`. + +Die Senkenfassung (`sink.db.expense` v1.1.0) erwartet immer absolute Beträge — Agent B löst percentage→absolute vor der Senkenübergabe auf und trägt die ursprüngliche `percentage` als informatives Feld mit. + +Beispiele unter `*/examples/vX.Y.Z/*.json` (werden in CI gegen das Schema derselben Version validiert). ## Namensschema diff --git a/schemas/expense/canonical/examples/v1.1.0/single-project.json b/schemas/expense/canonical/examples/v1.1.0/single-project.json new file mode 100644 index 0000000..040d3b8 --- /dev/null +++ b/schemas/expense/canonical/examples/v1.1.0/single-project.json @@ -0,0 +1,22 @@ +{ + "schema": { "name": "expense.canonical", "version": "1.1.0" }, + "workflow_id": "WF-2026-0042", + "employee_id": "EMP-12345", + "trip": { + "start_date": "2026-10-01", + "end_date": "2026-10-03", + "destination": "Berlin", + "purpose": "Kundentermin" + }, + "currency": "EUR", + "total_amount": "428.50", + "project_assignment": { + "project_code": "P-42", + "cost_center": "CC-100", + "source_attestation_ref": "sha256:0000000000000000000000000000000000000000000000000000000000000001" + }, + "expense_items": [ + { "type": "HOTEL", "amount": "328.50", "incurred_on": "2026-10-01" }, + { "type": "MEALS", "amount": "100.00", "incurred_on": "2026-10-02" } + ] +} diff --git a/schemas/expense/canonical/examples/v1.1.0/split-percentage.json b/schemas/expense/canonical/examples/v1.1.0/split-percentage.json new file mode 100644 index 0000000..6a798ef --- /dev/null +++ b/schemas/expense/canonical/examples/v1.1.0/split-percentage.json @@ -0,0 +1,49 @@ +{ + "schema": { "name": "expense.canonical", "version": "1.1.0" }, + "workflow_id": "WF-2026-0043", + "employee_id": "EMP-12345", + "trip": { + "start_date": "2026-10-01", + "end_date": "2026-10-03", + "destination": "Berlin", + "purpose": "Dual-Projekt-Workshop" + }, + "currency": "EUR", + "total_amount": "428.50", + "expense_items": [ + { + "type": "HOTEL", + "amount": "328.50", + "incurred_on": "2026-10-01", + "allocations": [ + { + "project_code": "P-42", "cost_center": "CC-100", + "mode": "percentage", "percentage": "60.00", + "source_attestation_ref": "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" + }, + { + "project_code": "P-99", "cost_center": "CC-200", + "mode": "percentage", "percentage": "40.00", + "source_attestation_ref": "sha256:bbbb000000000000000000000000000000000000000000000000000000000002" + } + ] + }, + { + "type": "MEALS", + "amount": "100.00", + "incurred_on": "2026-10-02", + "allocations": [ + { + "project_code": "P-42", "cost_center": "CC-100", + "mode": "absolute", "amount": "70.00", + "source_attestation_ref": "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" + }, + { + "project_code": "P-99", "cost_center": "CC-200", + "mode": "absolute", "amount": "30.00", + "source_attestation_ref": "sha256:bbbb000000000000000000000000000000000000000000000000000000000002" + } + ] + } + ] +} diff --git a/schemas/expense/canonical/v1.1.0.json b/schemas/expense/canonical/v1.1.0.json new file mode 100644 index 0000000..22124da --- /dev/null +++ b/schemas/expense/canonical/v1.1.0.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basicstack.de/schemas/expense/canonical/v1.1.0.json", + "title": "Expense (canonical) v1.1.0", + "description": "Kanonische Reisekostenstruktur. Additive gegenüber v1.0.0: expense_items[] erhalten ein optionales allocations[]-Array, mit dem einzelne Zeilen auf mehrere Projekte prozentual oder absolut aufgeteilt werden können. Das top-level project_assignment ist optional (für Vorgänge ohne Zeilen-Splits) — es gilt als Default-Zuordnung für alle items ohne allocations. OPA erzwingt: pro Item entweder top-level project_assignment oder item.allocations, nicht beides zugleich; alle Allocations eines Items im selben Modus; Summenerhalt (percentage=100.00 bzw. amount=item.amount).", + "type": "object", + "additionalProperties": false, + "required": ["schema", "workflow_id", "employee_id", "trip", "currency", "total_amount", "expense_items"], + "properties": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "const": "expense.canonical" }, + "version": { "const": "1.1.0" } + } + }, + "workflow_id": { + "type": "string", + "pattern": "^WF-[0-9]{4}-[0-9]{4,}$" + }, + "employee_id": { + "type": "string", + "pattern": "^EMP-[0-9]+$" + }, + "trip": { + "type": "object", + "additionalProperties": false, + "required": ["start_date", "end_date", "destination"], + "properties": { + "start_date": { "type": "string", "format": "date" }, + "end_date": { "type": "string", "format": "date" }, + "destination": { "type": "string", "minLength": 1, "maxLength": 200 }, + "purpose": { "type": "string", "maxLength": 500 } + } + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "total_amount": { + "type": "string", + "pattern": "^-?[0-9]+\\.[0-9]{2}$" + }, + "project_assignment": { + "description": "Optional. Default-Projektzuordnung für alle items ohne eigenes allocations[]. Wenn ein Item eigene allocations hat, wird dieser Default für dieses Item NICHT angewendet.", + "type": "object", + "additionalProperties": false, + "required": ["project_code", "cost_center", "source_attestation_ref"], + "properties": { + "project_code": { "type": "string", "pattern": "^P-[0-9]+$" }, + "cost_center": { "type": "string", "pattern": "^CC-[0-9]+$" }, + "source_attestation_ref": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + }, + "expense_items": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "amount", "incurred_on"], + "properties": { + "type": { + "type": "string", + "enum": ["HOTEL", "MEALS", "TRANSPORT_FLIGHT", "TRANSPORT_RAIL", "TRANSPORT_CAR", "PARKING", "COMMUNICATION", "CONFERENCE_FEE", "OTHER"] + }, + "amount": { "type": "string", "pattern": "^-?[0-9]+\\.[0-9]{2}$" }, + "incurred_on": { "type": "string", "format": "date" }, + "description": { "type": "string", "maxLength": 500 }, + "receipt_ref": { + "type": "string", + "pattern": "^pii://[A-Za-z0-9/._-]+$" + }, + "allocations": { + "description": "Optional. Aufteilung dieser Zeile auf ein oder mehrere Projekte. Wenn gesetzt, überschreibt das top-level project_assignment für dieses Item. Alle Entries desselben Items MÜSSEN denselben mode haben (OPA-Regel). Bei mode=percentage: Summe der percentage == 100.00. Bei mode=absolute: Summe der amount == item.amount.", + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["project_code", "cost_center", "mode", "source_attestation_ref"], + "properties": { + "project_code": { "type": "string", "pattern": "^P-[0-9]+$" }, + "cost_center": { "type": "string", "pattern": "^CC-[0-9]+$" }, + "mode": { "enum": ["percentage", "absolute"] }, + "percentage": { + "type": "string", + "pattern": "^(100\\.00|[0-9]{1,2}\\.[0-9]{2})$", + "description": "Vorhanden wenn mode=percentage; abwesend sonst. Zwei Nachkommastellen, 0.00..100.00." + }, + "amount": { + "type": "string", + "pattern": "^-?[0-9]+\\.[0-9]{2}$", + "description": "Vorhanden wenn mode=absolute; abwesend sonst." + }, + "source_attestation_ref": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "SHA-256 der Query Attestation (ds:expense-db-app / project_assignments), aus der dieses project_code+cost_center stammt." + } + }, + "allOf": [ + { + "if": { "properties": { "mode": { "const": "percentage" } }, "required": ["mode"] }, + "then": { "required": ["percentage"], "not": { "required": ["amount"] } } + }, + { + "if": { "properties": { "mode": { "const": "absolute" } }, "required": ["mode"] }, + "then": { "required": ["amount"], "not": { "required": ["percentage"] } } + } + ] + } + } + } + } + } + } +} diff --git a/schemas/sink/db/expense/examples/v1.1.0/split-resolved.json b/schemas/sink/db/expense/examples/v1.1.0/split-resolved.json new file mode 100644 index 0000000..5ce571a --- /dev/null +++ b/schemas/sink/db/expense/examples/v1.1.0/split-resolved.json @@ -0,0 +1,55 @@ +{ + "schema": { "name": "sink.db.expense", "version": "1.1.0" }, + "workflow_id": "WF-2026-0043", + "approval_id": "APR-2026-0043", + "decision_id": "DEC-2026-0043", + "verification_ref": "sha256:cccc000000000000000000000000000000000000000000000000000000000003", + "canonical_payload_hash": "sha256:dddd000000000000000000000000000000000000000000000000000000000004", + "expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", + "trip": { + "start_date": "2026-10-01", + "end_date": "2026-10-03", + "destination": "Berlin", + "purpose": "Dual-Projekt-Workshop" + }, + "items": [ + { + "type": "HOTEL", + "amount": "328.50", + "incurred_on": "2026-10-01", + "allocations": [ + { + "project_code": "P-42", "cost_center": "CC-100", "gl_account": "640100", + "amount": "197.10", "percentage": "60.00", + "source_attestation_ref": "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" + }, + { + "project_code": "P-99", "cost_center": "CC-200", "gl_account": "640100", + "amount": "131.40", "percentage": "40.00", + "source_attestation_ref": "sha256:bbbb000000000000000000000000000000000000000000000000000000000002" + } + ] + }, + { + "type": "MEALS", + "amount": "100.00", + "incurred_on": "2026-10-02", + "allocations": [ + { + "project_code": "P-42", "cost_center": "CC-100", "gl_account": "646000", + "amount": "70.00", + "source_attestation_ref": "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" + }, + { + "project_code": "P-99", "cost_center": "CC-200", "gl_account": "646000", + "amount": "30.00", + "source_attestation_ref": "sha256:bbbb000000000000000000000000000000000000000000000000000000000002" + } + ] + } + ] + } +} diff --git a/schemas/sink/db/expense/v1.1.0.json b/schemas/sink/db/expense/v1.1.0.json new file mode 100644 index 0000000..736695a --- /dev/null +++ b/schemas/sink/db/expense/v1.1.0.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basicstack.de/schemas/sink/db/expense/v1.1.0.json", + "title": "Sink Payload: sink:expense-db-app, action expense.post v1.1.0", + "description": "Senken-Payload für die eigene DB-App mit Zeilen-Allokationen. Agent-sink-mapping löst alle percentage-Modi aus expense.canonical v1.1.0 in absolute Beträge auf; die Senke erhält ausschließlich absolute amounts. Jedes Item trägt sein eigenes allocations[]-Array (Pflicht, mindestens 1 Entry). Die DB-App persistiert diese als expense_item_allocations-Zeilen (siehe docs/architecture/v1.1.md Abschnitt 7.2).", + "type": "object", + "additionalProperties": false, + "required": ["schema", "workflow_id", "approval_id", "decision_id", "verification_ref", "canonical_payload_hash", "expense"], + "properties": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "const": "sink.db.expense" }, + "version": { "const": "1.1.0" } + } + }, + "workflow_id": { "type": "string", "pattern": "^WF-[0-9]{4}-[0-9]{4,}$" }, + "approval_id": { "type": "string", "pattern": "^APR-[0-9]{4}-[0-9]{4,}$" }, + "decision_id": { "type": "string", "pattern": "^DEC-[0-9]{4}-[0-9]{4,}$" }, + "verification_ref": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "canonical_payload_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "expense": { + "type": "object", + "additionalProperties": false, + "required": ["employee_id", "currency", "total_amount", "trip", "items"], + "properties": { + "employee_id": { "type": "string", "pattern": "^EMP-[0-9]+$" }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }, + "total_amount": { "type": "string", "pattern": "^-?[0-9]+\\.[0-9]{2}$" }, + "trip": { + "type": "object", + "additionalProperties": false, + "required": ["start_date", "end_date", "destination"], + "properties": { + "start_date": { "type": "string", "format": "date" }, + "end_date": { "type": "string", "format": "date" }, + "destination": { "type": "string", "maxLength": 200 }, + "purpose": { "type": "string", "maxLength": 500 } + } + }, + "items": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "amount", "incurred_on", "allocations"], + "properties": { + "type": { + "type": "string", + "enum": ["HOTEL", "MEALS", "TRANSPORT_FLIGHT", "TRANSPORT_RAIL", "TRANSPORT_CAR", "PARKING", "COMMUNICATION", "CONFERENCE_FEE", "OTHER"] + }, + "amount": { "type": "string", "pattern": "^-?[0-9]+\\.[0-9]{2}$" }, + "incurred_on": { "type": "string", "format": "date" }, + "description": { "type": "string", "maxLength": 500 }, + "receipt_ref": { "type": "string", "pattern": "^pii://[A-Za-z0-9/._-]+$" }, + "allocations": { + "description": "Immer gesetzt in der Senken-Fassung. Mindestens 1 Entry. Alle amounts sind absolut (percentage ist informativ, falls bekannt). Summe aller amount == item.amount.", + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["project_code", "cost_center", "gl_account", "amount", "source_attestation_ref"], + "properties": { + "project_code": { "type": "string", "pattern": "^P-[0-9]+$" }, + "cost_center": { "type": "string", "pattern": "^CC-[0-9]+$" }, + "gl_account": { "type": "string", "pattern": "^[0-9]{4,6}$" }, + "amount": { "type": "string", "pattern": "^-?[0-9]+\\.[0-9]{2}$" }, + "percentage": { + "type": "string", + "pattern": "^(100\\.00|[0-9]{1,2}\\.[0-9]{2})$", + "description": "Optional. Nur gesetzt, wenn canonical mode=percentage war — als Herkunftsnachweis." + }, + "source_attestation_ref": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + } + } + } + } + } + } + } + } +}