From f8d1c542d5c4eacee83a6f454262088d039a6f21 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 14 Aug 2026 13:15:02 +0200 Subject: [PATCH] feat: operational resource procurement facility Implement RESOURCE-WP-0005: entity register, V0.1 terms parameters, entity association on inventory and planning records, transfer-price and credit-state arithmetic, monthly settlement, and entity views on the portfolio report. Live close emits nothing until delivered cost is known. Handoffs are FIN-WP-0006 and RAILIANCE-WP-0017. --- Makefile | 6 +- WORK-RECORDS.md | 16 +- data/control-cycle/apps-pg-2026-09-base.json | 4 + data/entities/register.json | 54 +++ data/resources/apps-pg.json | 11 +- data/resources/binky-rapp-qonto.json | 6 +- data/resources/coulomb-social-production.json | 6 +- data/resources/hosteurope-railiance01.json | 6 +- .../platform-audit-storage.proposed.json | 3 + data/resources/railiance-forgejo.json | 6 +- data/resources/reef-railiance-k3s.json | 6 +- data/terms/procurement-v0.1.json | 15 + .../fin-hub-resource-control-contract-v0.1.md | 18 + docs/portfolio-operating-cadence.md | 2 +- docs/resource-procurement-facility.md | 88 +++++ .../planning/entity-forecast-transfer.json | 41 ++ .../portfolio/garage-three-node.example.json | 6 +- .../kubernetes-capacity.example.json | 6 +- .../shared-platform-service.example.json | 6 +- examples/settlement/15.1-ordinary.json | 17 + examples/settlement/15.2-extended.json | 19 + examples/settlement/15.3-restricted.json | 12 + schemas/financial-entity-register.schema.json | 29 ++ schemas/planning-evidence.schema.json | 5 + schemas/procurement-terms.schema.json | 37 ++ schemas/resource-control-cycle.schema.json | 15 + schemas/resource-inventory.schema.json | 17 +- schemas/settlement-statement.schema.json | 54 +++ tests/test_entities.py | 92 +++++ tests/test_portfolio_report.py | 12 + tests/test_settlement.py | 116 ++++++ tools/entities.py | 144 ++++++++ tools/portfolio.py | 4 + tools/portfolio_report.py | 44 +++ tools/settlement.py | 349 ++++++++++++++++++ tools/validate.py | 29 ++ ...E-WP-0005-resource-procurement-facility.md | 43 ++- 37 files changed, 1316 insertions(+), 28 deletions(-) create mode 100644 data/entities/register.json create mode 100644 data/terms/procurement-v0.1.json create mode 100644 docs/resource-procurement-facility.md create mode 100644 examples/planning/entity-forecast-transfer.json create mode 100644 examples/settlement/15.1-ordinary.json create mode 100644 examples/settlement/15.2-extended.json create mode 100644 examples/settlement/15.3-restricted.json create mode 100644 schemas/financial-entity-register.schema.json create mode 100644 schemas/procurement-terms.schema.json create mode 100644 schemas/settlement-statement.schema.json create mode 100644 tests/test_entities.py create mode 100644 tests/test_settlement.py create mode 100644 tools/entities.py create mode 100644 tools/settlement.py diff --git a/Makefile b/Makefile index 94c2966..3cef382 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test forecast variance control-cycle exchange-forecast optimization portfolio-report +.PHONY: test forecast variance control-cycle exchange-forecast optimization portfolio-report settlement test: python3 -m unittest discover -s tests -p 'test_*.py' @@ -23,3 +23,7 @@ optimization: portfolio-report: python3 tools/portfolio_report.py . + +settlement: + @test -n "$(PERIOD)" || { echo 'PERIOD=YYYY-MM is required' >&2; exit 2; } + python3 tools/settlement.py close --period $(PERIOD) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2377b40..a07d974 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,7 @@ | workplan | RESOURCE-WP-0002 | active | — | workplans/RESOURCE-WP-0002-procure-postgres-backup-storage.md | | workplan | RESOURCE-WP-0003 | finished | — | workplans/RESOURCE-WP-0003-managed-infrastructure-portfolio-control.md | | workplan | RESOURCE-WP-0004 | finished | — | workplans/RESOURCE-WP-0004-financial-entity-procurement-terms.md | -| workplan | RESOURCE-WP-0005 | ready | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| workplan | RESOURCE-WP-0005 | finished | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | | task | RESOURCE-WP-0001-T01 | done | — | workplans/RESOURCE-WP-0001-statehub-bootstrap.md | | task | RESOURCE-WP-0001-T02 | done | — | workplans/RESOURCE-WP-0001-statehub-bootstrap.md | | task | RESOURCE-WP-0001-T03 | done | — | workplans/RESOURCE-WP-0001-statehub-bootstrap.md | @@ -35,10 +35,10 @@ | task | RESOURCE-WP-0004-T03 | cancel | — | workplans/RESOURCE-WP-0004-financial-entity-procurement-terms.md | | task | RESOURCE-WP-0004-T04 | cancel | — | workplans/RESOURCE-WP-0004-financial-entity-procurement-terms.md | | task | RESOURCE-WP-0004-T05 | cancel | — | workplans/RESOURCE-WP-0004-financial-entity-procurement-terms.md | -| task | RESOURCE-WP-0005-T01 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T02 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T03 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T04 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T05 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T06 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | -| task | RESOURCE-WP-0005-T07 | todo | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T01 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T02 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T03 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T04 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T05 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T06 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | +| task | RESOURCE-WP-0005-T07 | done | — | workplans/RESOURCE-WP-0005-resource-procurement-facility.md | diff --git a/data/control-cycle/apps-pg-2026-09-base.json b/data/control-cycle/apps-pg-2026-09-base.json index 45949df..4f6c083 100644 --- a/data/control-cycle/apps-pg-2026-09-base.json +++ b/data/control-cycle/apps-pg-2026-09-base.json @@ -3,6 +3,10 @@ "record_id": "forecast:apps-pg:2026-09:base:v1", "record_type": "forecast", "resource_id": "resource:railiance:apps-pg", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "shared platform service; coulomb share is allocated, no single consumer", + "transfer_price": null, "resource_class": "shared_platform_service", "period": "2026-09", "created_at": "2026-08-14T00:00:00Z", diff --git a/data/entities/register.json b/data/entities/register.json new file mode 100644 index 0000000..657089b --- /dev/null +++ b/data/entities/register.json @@ -0,0 +1,54 @@ +{ + "schema_version": "0.1", + "terms_version": "0.1", + "entities": [ + { + "id": "entity:binky", + "display_name": "Binky", + "role": "umbrella", + "credit_limit_eur": "1000.00", + "account_ref": null, + "status": "active" + }, + { + "id": "entity:frontier", + "display_name": "Frontier", + "role": "consumer", + "credit_limit_eur": "1000.00", + "account_ref": null, + "status": "active" + }, + { + "id": "entity:railiance", + "display_name": "Railiance", + "role": "provider", + "credit_limit_eur": null, + "account_ref": null, + "status": "active" + }, + { + "id": "entity:netkingdom", + "display_name": "Netkingdom", + "role": "consumer", + "credit_limit_eur": "1000.00", + "account_ref": null, + "status": "active" + }, + { + "id": "entity:helixforge", + "display_name": "Helixforge", + "role": "consumer", + "credit_limit_eur": "1000.00", + "account_ref": null, + "status": "active" + }, + { + "id": "entity:coulomb", + "display_name": "Coulomb", + "role": "consumer", + "credit_limit_eur": "1000.00", + "account_ref": null, + "status": "active" + } + ] +} diff --git a/data/resources/apps-pg.json b/data/resources/apps-pg.json index beb1854..7f94f9b 100644 --- a/data/resources/apps-pg.json +++ b/data/resources/apps-pg.json @@ -2,6 +2,9 @@ "schema_version": "0.2", "record_scope": "inventory", "id": "resource:railiance:apps-pg", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "shared allocation; coulomb-social maps to entity:coulomb; vergabe-teilnahme has no financial entity", "resource_class": "database", "status": "active", "management_model": "self_managed", @@ -104,7 +107,13 @@ "mode": "shared", "cost_attribution_key": "platform:apps-pg", "driver": "database_gb", - "method_version": "apps-pg-dbbytes-v1" + "method_version": "apps-pg-dbbytes-v1", + "entity_shares": [ + { + "financial_entity_id": "entity:coulomb", + "note": "coulomb-social database_gb share 0.5386 under apps-pg-dbbytes-v1" + } + ] } }, "cost": { diff --git a/data/resources/binky-rapp-qonto.json b/data/resources/binky-rapp-qonto.json index 482425c..3b34b8a 100644 --- a/data/resources/binky-rapp-qonto.json +++ b/data/resources/binky-rapp-qonto.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "inventory", - "id": "resource:tenant:friendly:binky:rapp-qonto", "resource_class": "self_managed_service", + "id": "resource:tenant:friendly:binky:rapp-qonto", + "financial_entity_id": "entity:binky", + "procuring_entity_id": "entity:railiance", + "entity_gap": null, + "resource_class": "self_managed_service", "status": "active", "management_model": "shared_capacity", "provider": {"name": "Railiance", "account_ref": null, "product_ref": "rail-knative", "provider_resource_id": "knative:rapp-qonto/rapp-qonto"}, "service": {"name": "Binky Qonto assistant", "service_id": "rapp-qonto", "class": "tenant Knative application with dedicated egress proxy", "capacity_model": "elastic"}, diff --git a/data/resources/coulomb-social-production.json b/data/resources/coulomb-social-production.json index 8d22c6c..49b0271 100644 --- a/data/resources/coulomb-social-production.json +++ b/data/resources/coulomb-social-production.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "inventory", - "id": "resource:tenant:coulomb:coulomb-social", "resource_class": "self_managed_service", + "id": "resource:tenant:coulomb:coulomb-social", + "financial_entity_id": "entity:coulomb", + "procuring_entity_id": "entity:railiance", + "entity_gap": null, + "resource_class": "self_managed_service", "status": "active", "management_model": "shared_capacity", "provider": {"name": "Railiance", "account_ref": null, "product_ref": "coulomb-social", "provider_resource_id": "kubernetes:coulomb-social/deployment/coulomb-social"}, "service": {"name": "Coulomb Social production", "service_id": "coulomb-social", "class": "tenant application", "capacity_model": "shared"}, diff --git a/data/resources/hosteurope-railiance01.json b/data/resources/hosteurope-railiance01.json index 01b6d55..5b57d9b 100644 --- a/data/resources/hosteurope-railiance01.json +++ b/data/resources/hosteurope-railiance01.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "inventory", - "id": "resource:hosteurope:railiance01", "resource_class": "compute_instance", + "id": "resource:hosteurope:railiance01", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "no allocation driver; cost remains a Railiance residual", + "resource_class": "compute_instance", "status": "active", "management_model": "provider_managed", "provider": {"name": "Host Europe", "account_ref": null, "product_ref": null, "provider_resource_id": null}, "service": {"name": "railiance01", "service_id": "railiance01", "class": "virtual server", "capacity_model": "fixed"}, diff --git a/data/resources/platform-audit-storage.proposed.json b/data/resources/platform-audit-storage.proposed.json index f863ce0..49dba12 100644 --- a/data/resources/platform-audit-storage.proposed.json +++ b/data/resources/platform-audit-storage.proposed.json @@ -2,6 +2,9 @@ "schema_version": "0.2", "record_scope": "inventory", "id": "resource:platform:audit-storage", + "financial_entity_id": "entity:railiance", + "procuring_entity_id": "entity:railiance", + "entity_gap": null, "resource_class": "storage", "status": "proposed", "management_model": "provider_managed", diff --git a/data/resources/railiance-forgejo.json b/data/resources/railiance-forgejo.json index 5db12cd..5e28f3a 100644 --- a/data/resources/railiance-forgejo.json +++ b/data/resources/railiance-forgejo.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "inventory", - "id": "resource:railiance:forgejo", "resource_class": "shared_platform_service", + "id": "resource:railiance:forgejo", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "no allocation driver; cost remains a Railiance residual", + "resource_class": "shared_platform_service", "status": "active", "management_model": "self_managed", "provider": {"name": "Railiance", "account_ref": null, "product_ref": "forgejo-11.0.3", "provider_resource_id": "kubernetes:forgejo/deployment/forgejo-gitea"}, "service": {"name": "Forgejo source and package forge", "service_id": "forgejo", "class": "shared source forge and package registry", "capacity_model": "shared"}, diff --git a/data/resources/reef-railiance-k3s.json b/data/resources/reef-railiance-k3s.json index d1729a3..8b1ea8c 100644 --- a/data/resources/reef-railiance-k3s.json +++ b/data/resources/reef-railiance-k3s.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "inventory", - "id": "resource:railiance:reef-railiance:k3s", "resource_class": "kubernetes_capacity", + "id": "resource:railiance:reef-railiance:k3s", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "no allocation driver; cost remains a Railiance residual", + "resource_class": "kubernetes_capacity", "status": "active", "management_model": "self_managed", "provider": {"name": "Railiance", "account_ref": null, "product_ref": "k3s", "provider_resource_id": "k3s://239.62.205.92.host.secureserver.net"}, "service": {"name": "reef-railiance k3s", "service_id": "reef-railiance", "class": "single-node Kubernetes runtime", "capacity_model": "shared"}, diff --git a/data/terms/procurement-v0.1.json b/data/terms/procurement-v0.1.json new file mode 100644 index 0000000..0eaaca1 --- /dev/null +++ b/data/terms/procurement-v0.1.json @@ -0,0 +1,15 @@ +{ + "schema_version": "0.1", + "terms_version": "0.1", + "currency": "EUR", + "markup_rate": "0.20", + "railiance_self_markup_rate": "0.00", + "settlement_period": "calendar_month", + "payment_term_days": 10, + "interest_rate_per_year": "0.05", + "interest_convention": "simple_monthly", + "default_credit_limit_eur": "1000.00", + "restricted_monthly_consumption_eur": "50.00", + "procuring_entity_id": "entity:railiance", + "labor_rate_eur_per_hour": "60.00" +} diff --git a/docs/fin-hub-resource-control-contract-v0.1.md b/docs/fin-hub-resource-control-contract-v0.1.md index a7fa2a5..f09fd05 100644 --- a/docs/fin-hub-resource-control-contract-v0.1.md +++ b/docs/fin-hub-resource-control-contract-v0.1.md @@ -38,6 +38,24 @@ Fin-hub owns its outbound `BookedCostEvidence` executable schema. This repository validates the contract fields and arithmetic it consumes, but does not redefine financial authority. +## Internal settlement (RESOURCE-WP-0005) + +`schemas/settlement-statement.schema.json` is the outbound shape for proposed +internal transfer charges. It is planning/settlement evidence, not a booked +fact. Join keys added by the facility: + +- `financial_entity_id` (`entity:…`) +- `procuring_entity_id` (V0.1: `entity:railiance`) +- `period`, `terms_version` + +A recognized internal payment, when it exists, is a fin-hub booked fact +joined by `financial_fact_id` + entity + period. resource-control never +clears outstanding from its own statement. + +The consumption-mode signal (`open` | `restricted`, remaining transfer-price +allowance) is published for Railiance operations. It is not a fin-hub +budget. Handoff workplans: `FIN-WP-0006`, `RAILIANCE-WP-0017`. + ## Acceptance boundary The 12-row `platform:audit-storage` forecast can complete an idempotent diff --git a/docs/portfolio-operating-cadence.md b/docs/portfolio-operating-cadence.md index 5e20e64..1f916a3 100644 --- a/docs/portfolio-operating-cadence.md +++ b/docs/portfolio-operating-cadence.md @@ -42,7 +42,7 @@ reason, rather than being silently omitted from the ratios. | Rhythm | When | Inputs | Consumers | Output | |---|---|---|---|---| | **Monthly observation** | First week, for the closed month | Monthly actual records from delegated telemetry; booked costs from `fin-hub` | resource-control | `tools/control_cycle.py` variance per resource; a new optimization case where variance is attributed to demand, price, or allocation rather than data quality | -| **Monthly settlement** | With the observation, once entity association is live | Per-entity usage, delivered cost, transfer prices under `docs/TermsOfResourceProcurement.md`; recognized payments from `fin-hub` | consuming financial entities; Railiance operations; `fin-hub` | One settlement statement and credit-state evaluation (`open` or `restricted`) per consuming entity | +| **Monthly settlement** | With the observation | Per-entity usage, delivered cost, transfer prices under `docs/TermsOfResourceProcurement.md`; recognized payments from `fin-hub` | consuming financial entities; Railiance operations; `fin-hub` | `make settlement PERIOD=YYYY-MM`; empty output if nothing is known to settle. Runbook: `docs/resource-procurement-facility.md` | | **Monthly portfolio report** | With the observation | Resource records, coverage, optimization cases | Human operator; owning repositories | `make portfolio-report`; the `next_actions` list is re-sent to the repositories named in it | | **Quarterly calibration** | End of quarter | Three months of variance | resource-control; `fin-hub` | Forecast revisions per `docs/forecast-actual-control.md` — revisions, never overwrites; refreshed provider prices in every open case | | **Pre-renewal review** | At least 60 days before the earliest `renews_on` or `cancel_by` | Contract evidence, utilization, open cases | Human financial authority | A `renewal` case, decided while cancellation is still possible | diff --git a/docs/resource-procurement-facility.md b/docs/resource-procurement-facility.md new file mode 100644 index 0000000..50b9ff2 --- /dev/null +++ b/docs/resource-procurement-facility.md @@ -0,0 +1,88 @@ +# Resource procurement facility — operator runbook + +Status: operational V0.1 (RESOURCE-WP-0005) +Terms: `docs/TermsOfResourceProcurement.md` +Parameters: `data/terms/procurement-v0.1.json` +Entities: `data/entities/register.json` + +This is the monthly close for internal transfer prices. It does not book +spend, move money, or throttle a cluster. + +## Monthly close + +1. Close technical actuals for month `M` (`docs/portfolio-operating-cadence.md`). +2. Pull booked provider cost and any recognized internal payments from + `fin-hub`. If none exist, payment recognition stays `unknown`. +3. Run: + + ```text + make test + make settlement PERIOD=YYYY-MM + make portfolio-report + ``` + +4. `make settlement` prints one JSON statement per consuming entity that + has a charge, credit, interest, or open balance. An empty array means + there was nothing to settle — not a zero bill. +5. Send the statements and the consumption-mode signal to `fin-hub` and + Railiance operations. Do not post them as booked spend here. + +Statement date defaults to the first calendar day of `M+1`. Due date is +that date plus 10 calendar days. Interest is 5 % per year, simple monthly, +on overdue outstanding only. + +## What is unknown without bank accounts + +- `account_ref` on every entity is `null`. Pay-from / pay-to are present + as nulls, not invented. +- Recognized payments are `unknown` until `fin-hub` projects a payment + fact. Do not clear a balance from a chat message. +- Live `make settlement PERIOD=2026-08` emits `[]` because no control-cycle + actual has a known delivered-cost total and a consuming entity. That is + the correct answer. + +## Human waiver + +A limit change, interest waiver, or temporary unrestricted window is a +dated decision by human financial authority. Record it as evidence and +change the entity's `credit_limit_eur` in the register, or add a +successor terms file. Do not edit historical statements. + +## Failure modes and owners + +| Failure | Owner | +| --- | --- | +| Missing entity or untagged cost object | `resource-control` | +| Wrong transfer-price arithmetic | `resource-control` | +| Booked provider amount, tax, recognized payment | `fin-hub` | +| Restricted entity still consuming above allowance | Railiance platform / procurement | +| Bank account missing | Human financial authority | +| Parameter change (markup, limit, interest) | Human financial authority; new terms version | + +## Consumption-mode signal + +Railiance operations consumes: + +```json +{ + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": "entity:coulomb", + "period": "2026-08", + "consumption_mode": "open", + "new_transfer_charges_allowed_eur": null, + "terms_version": "0.1" +} +``` + +`restricted` plus a non-null allowance is the only signal that should +refuse new orders or elastic usage. Committed provider contracts are +flagged, not cancelled (Terms OQ-2). + +## Transfer-charge projection to fin-hub + +`resource-control` publishes settlement statements as proposed charges. +Join keys: `financial_entity_id`, `period`, `resource_id`, +`terms_version`. A later booked internal settlement in `fin-hub` must +reference the statement, not replace it. See +`docs/fin-hub-resource-control-contract-v0.1.md`. diff --git a/examples/planning/entity-forecast-transfer.json b/examples/planning/entity-forecast-transfer.json new file mode 100644 index 0000000..f389300 --- /dev/null +++ b/examples/planning/entity-forecast-transfer.json @@ -0,0 +1,41 @@ +{ + "schema_version": "0.1", + "record_type": "forecast", + "record_id": "forecast:example:coulomb:cluster-share:2026-08:base", + "revision_of": null, + "resource_id": "resource:example:coulomb-cluster-share", + "service_id": "cluster-share", + "workload_id": "coulomb-social", + "tenant_id": "tenant:coulomb", + "environment": "production", + "cost_attribution_key": "entity:coulomb|resource:example:coulomb-cluster-share", + "financial_entity_id": "entity:coulomb", + "procuring_entity_id": "entity:railiance", + "entity_gap": null, + "period_start": "2026-08-01", + "period_end": "2026-08-31", + "source_evidence": [ + "docs/TermsOfResourceProcurement.md#15-worked-examples", + "examples/settlement/15.1-ordinary.json" + ], + "created_at": "2026-08-14T12:00:00Z", + "currency": "EUR", + "scenario": "base", + "forecast_version": "terms-v0.1:2026-08-14", + "costs": { + "infrastructure": "200.00", + "internal_labor": "0.00", + "external_services": "0.00", + "setup": "0.00", + "other": "0.00" + }, + "transfer_price": "240.00", + "credit_headroom": "760.00", + "uncertainty": "illustrative Terms § 15.1 fixture; not booked spend", + "assumptions": [ + "delivered_cost=200.00", + "markup_rate=0.20", + "prior_outstanding=0.00", + "credit_limit=1000.00" + ] +} diff --git a/examples/portfolio/garage-three-node.example.json b/examples/portfolio/garage-three-node.example.json index da2064c..4ac941c 100644 --- a/examples/portfolio/garage-three-node.example.json +++ b/examples/portfolio/garage-three-node.example.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "example", - "id": "resource:example:garage:cluster", "resource_class": "self_managed_service", + "id": "resource:example:garage:cluster", + "financial_entity_id": "entity:railiance", + "procuring_entity_id": "entity:railiance", + "entity_gap": null, + "resource_class": "self_managed_service", "status": "proposed", "management_model": "self_managed", "provider": {"name": "Example IaaS provider", "account_ref": null, "product_ref": "three-vm-topology", "provider_resource_id": null}, "service": {"name": "Garage object storage", "service_id": "object-storage", "class": "S3-compatible replicated service", "capacity_model": "fixed"}, diff --git a/examples/portfolio/kubernetes-capacity.example.json b/examples/portfolio/kubernetes-capacity.example.json index 8c66c2e..5a04a6a 100644 --- a/examples/portfolio/kubernetes-capacity.example.json +++ b/examples/portfolio/kubernetes-capacity.example.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "example", - "id": "resource:example:railiance:kubernetes-capacity", "resource_class": "kubernetes_capacity", + "id": "resource:example:railiance:kubernetes-capacity", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "example shared capacity; consumers would be allocated, not defaulted", + "resource_class": "kubernetes_capacity", "status": "active", "management_model": "shared_capacity", "provider": {"name": "Example infrastructure provider", "account_ref": "account:example", "product_ref": "virtual-machines", "provider_resource_id": null}, "service": {"name": "Railiance Kubernetes worker capacity", "service_id": "kubernetes", "class": "shared worker pool", "capacity_model": "shared"}, diff --git a/examples/portfolio/shared-platform-service.example.json b/examples/portfolio/shared-platform-service.example.json index 6e55acf..32833e9 100644 --- a/examples/portfolio/shared-platform-service.example.json +++ b/examples/portfolio/shared-platform-service.example.json @@ -1,6 +1,10 @@ { "schema_version": "0.2", "record_scope": "example", - "id": "resource:example:shared:ingress", "resource_class": "shared_platform_service", + "id": "resource:example:shared:ingress", + "financial_entity_id": null, + "procuring_entity_id": "entity:railiance", + "entity_gap": "example shared ingress; helixforge and coulomb would receive allocated shares", + "resource_class": "shared_platform_service", "status": "active", "management_model": "self_managed", "provider": {"name": "Railiance", "account_ref": null, "product_ref": "platform-ingress", "provider_resource_id": null}, "service": {"name": "Shared application ingress", "service_id": "platform-ingress", "class": "cluster platform service", "capacity_model": "shared"}, diff --git a/examples/settlement/15.1-ordinary.json b/examples/settlement/15.1-ordinary.json new file mode 100644 index 0000000..c287167 --- /dev/null +++ b/examples/settlement/15.1-ordinary.json @@ -0,0 +1,17 @@ +{ + "scenario": "15.1-ordinary", + "period": "2026-08", + "statement_date": "2026-09-01", + "as_of": "2026-09-01", + "payment_recognition": "unknown", + "charges": [ + { + "financial_entity_id": "entity:coulomb", + "resource_id": "resource:example:coulomb-cluster-share", + "delivered_cost": "200.00", + "usage_summary": "allocated cluster share" + } + ], + "prior_outstanding": {}, + "recognized_payments": [] +} diff --git a/examples/settlement/15.2-extended.json b/examples/settlement/15.2-extended.json new file mode 100644 index 0000000..76a346c --- /dev/null +++ b/examples/settlement/15.2-extended.json @@ -0,0 +1,19 @@ +{ + "scenario": "15.2-extended", + "period": "2026-09", + "statement_date": "2026-10-01", + "as_of": "2026-09-30", + "payment_recognition": "unknown", + "charges": [ + { + "financial_entity_id": "entity:coulomb", + "resource_id": "resource:example:coulomb-cluster-share", + "delivered_cost": "100.00", + "usage_summary": "allocated cluster share" + } + ], + "prior_outstanding": { + "entity:coulomb": "240.00" + }, + "recognized_payments": [] +} diff --git a/examples/settlement/15.3-restricted.json b/examples/settlement/15.3-restricted.json new file mode 100644 index 0000000..42be529 --- /dev/null +++ b/examples/settlement/15.3-restricted.json @@ -0,0 +1,12 @@ +{ + "scenario": "15.3-restricted", + "period": "2026-09", + "statement_date": "2026-10-01", + "as_of": "2026-09-30", + "payment_recognition": "unknown", + "charges": [], + "prior_outstanding": { + "entity:helixforge": "1000.00" + }, + "recognized_payments": [] +} diff --git a/schemas/financial-entity-register.schema.json b/schemas/financial-entity-register.schema.json new file mode 100644 index 0000000..adec446 --- /dev/null +++ b/schemas/financial-entity-register.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://coulomb.social/resource-control/financial-entity-register.schema.json", + "title": "Financial entity register v0.1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "terms_version", "entities"], + "properties": { + "schema_version": {"const": "0.1"}, + "terms_version": {"type": "string", "minLength": 1}, + "entities": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "display_name", "role", "credit_limit_eur", "account_ref", "status"], + "properties": { + "id": {"type": "string", "pattern": "^entity:[a-z0-9]+$"}, + "display_name": {"type": "string", "minLength": 1}, + "role": {"enum": ["umbrella", "provider", "consumer"]}, + "credit_limit_eur": {"type": ["string", "null"], "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "account_ref": {"type": ["string", "null"]}, + "status": {"enum": ["active", "suspended", "retired"]} + } + } + } + } +} diff --git a/schemas/planning-evidence.schema.json b/schemas/planning-evidence.schema.json index 469e14c..614b956 100644 --- a/schemas/planning-evidence.schema.json +++ b/schemas/planning-evidence.schema.json @@ -25,6 +25,9 @@ "tenant_id": {"type": ["string", "null"]}, "environment": {"type": ["string", "null"]}, "cost_attribution_key": {"type": ["string", "null"]}, + "financial_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"}, + "procuring_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"}, + "entity_gap": {"type": ["string", "null"]}, "period_start": {"type": "string", "format": "date"}, "period_end": {"type": "string", "format": "date"}, "source_evidence": {"type": "array", "items": {"type": "string"}}, @@ -55,6 +58,8 @@ "scenario": {"enum": ["low", "base", "high"]}, "forecast_version": {"type": "string", "minLength": 1}, "costs": {"$ref": "#/$defs/cost_breakdown"}, + "transfer_price": {"$ref": "#/$defs/money"}, + "credit_headroom": {"type": ["string", "null"], "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, "uncertainty": {"type": ["string", "null"]}, "assumptions": {"type": "array", "items": {"type": "string"}} } diff --git a/schemas/procurement-terms.schema.json b/schemas/procurement-terms.schema.json new file mode 100644 index 0000000..878a3a1 --- /dev/null +++ b/schemas/procurement-terms.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://coulomb.social/resource-control/procurement-terms.schema.json", + "title": "Terms of Resource Procurement parameters", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "terms_version", + "currency", + "markup_rate", + "railiance_self_markup_rate", + "settlement_period", + "payment_term_days", + "interest_rate_per_year", + "interest_convention", + "default_credit_limit_eur", + "restricted_monthly_consumption_eur", + "procuring_entity_id", + "labor_rate_eur_per_hour" + ], + "properties": { + "schema_version": {"const": "0.1"}, + "terms_version": {"type": "string", "minLength": 1}, + "currency": {"const": "EUR"}, + "markup_rate": {"type": "string", "pattern": "^0\\.[0-9]{2}$"}, + "railiance_self_markup_rate": {"type": "string", "pattern": "^0\\.[0-9]{2}$"}, + "settlement_period": {"const": "calendar_month"}, + "payment_term_days": {"type": "integer", "minimum": 1}, + "interest_rate_per_year": {"type": "string", "pattern": "^0\\.[0-9]{2}$"}, + "interest_convention": {"const": "simple_monthly"}, + "default_credit_limit_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "restricted_monthly_consumption_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "procuring_entity_id": {"type": "string", "pattern": "^entity:[a-z0-9]+$"}, + "labor_rate_eur_per_hour": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"} + } +} diff --git a/schemas/resource-control-cycle.schema.json b/schemas/resource-control-cycle.schema.json index 683ab58..57cce6c 100644 --- a/schemas/resource-control-cycle.schema.json +++ b/schemas/resource-control-cycle.schema.json @@ -41,6 +41,21 @@ "type": "string", "pattern": "^resource:" }, + "financial_entity_id": { + "type": ["string", "null"], + "pattern": "^entity:[a-z0-9]+$" + }, + "procuring_entity_id": { + "type": ["string", "null"], + "pattern": "^entity:[a-z0-9]+$" + }, + "entity_gap": { + "type": ["string", "null"] + }, + "transfer_price": { + "type": ["number", "null"], + "minimum": 0 + }, "resource_class": { "enum": [ "storage", diff --git a/schemas/resource-inventory.schema.json b/schemas/resource-inventory.schema.json index 3782c57..2f3f719 100644 --- a/schemas/resource-inventory.schema.json +++ b/schemas/resource-inventory.schema.json @@ -9,6 +9,9 @@ "schema_version": {"const": "0.2"}, "record_scope": {"enum": ["inventory", "example"]}, "id": {"type": "string", "pattern": "^resource:[a-z0-9][a-z0-9:_-]+$"}, + "financial_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"}, + "procuring_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"}, + "entity_gap": {"type": ["string", "null"]}, "resource_class": {"enum": ["compute_instance", "storage", "network", "kubernetes_capacity", "database", "managed_service", "self_managed_service", "shared_platform_service", "license"]}, "status": {"enum": ["proposed", "ordered", "commissioning", "active", "suspended", "retiring", "retired", "rejected"]}, "management_model": {"enum": ["provider_managed", "self_managed", "shared_capacity"]}, @@ -71,7 +74,19 @@ "mode": {"enum": ["dedicated", "shared", "unattributed"]}, "cost_attribution_key": {"type": ["string", "null"]}, "driver": {"type": ["string", "null"]}, - "method_version": {"type": ["string", "null"]} + "method_version": {"type": ["string", "null"]}, + "entity_shares": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["financial_entity_id", "note"], + "properties": { + "financial_entity_id": {"type": "string", "pattern": "^entity:[a-z0-9]+$"}, + "note": {"type": "string"} + } + } + } } } } diff --git a/schemas/settlement-statement.schema.json b/schemas/settlement-statement.schema.json new file mode 100644 index 0000000..b4a20ea --- /dev/null +++ b/schemas/settlement-statement.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://coulomb.social/resource-control/settlement-statement.schema.json", + "title": "Internal settlement statement v0.1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "record_type", + "terms_version", + "financial_entity_id", + "procuring_entity_id", + "period", + "statement_date", + "due_date", + "currency", + "line_items", + "known_delivered_cost_eur", + "known_transfer_price_eur", + "unknown_cost_remainder", + "prior_outstanding_eur", + "payment_recognition", + "interest_eur", + "new_transfer_charges_eur", + "outstanding_eur", + "consumption_mode" + ], + "properties": { + "schema_version": {"const": "0.1"}, + "record_type": {"const": "settlement_statement"}, + "terms_version": {"type": "string", "minLength": 1}, + "financial_entity_id": {"type": "string", "pattern": "^entity:[a-z0-9]+$"}, + "procuring_entity_id": {"type": "string", "pattern": "^entity:[a-z0-9]+$"}, + "period": {"type": "string", "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$"}, + "statement_date": {"type": "string", "format": "date"}, + "due_date": {"type": "string", "format": "date"}, + "currency": {"const": "EUR"}, + "line_items": {"type": "array"}, + "known_delivered_cost_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "known_transfer_price_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "unknown_cost_remainder": {"type": "boolean"}, + "prior_outstanding_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "recognized_payments_eur": {"type": ["string", "null"]}, + "payment_recognition": {"enum": ["unknown", "recognized"]}, + "interest_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "new_transfer_charges_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "outstanding_eur": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{2}$"}, + "credit_limit_eur": {"type": ["string", "null"]}, + "credit_headroom_eur": {"type": ["string", "null"]}, + "consumption_mode": {"enum": ["open", "restricted"]}, + "next_month_allowance_eur": {"type": ["string", "null"]}, + "payment_instruction": {"type": ["object", "null"]} + } +} diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..b6e39f4 --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,92 @@ +import json +import sys +import unittest +from copy import deepcopy +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from entities import ( + REQUIRED_ENTITY_IDS, + V0_1_PARAMETERS, + association_ok, + load_register, + load_terms, + require_entity, + validate_register, + validate_terms, +) +from portfolio import validate_record + + +class EntityRegisterTest(unittest.TestCase): + def test_register_contains_exactly_the_six_entities(self): + _, entities = load_register(ROOT) + self.assertEqual(set(REQUIRED_ENTITY_IDS), set(entities)) + self.assertEqual("provider", entities["entity:railiance"]["role"]) + self.assertIsNone(entities["entity:railiance"]["credit_limit_eur"]) + + def test_unknown_entity_id_is_rejected(self): + with self.assertRaisesRegex(ValueError, "unknown entity id"): + require_entity("entity:unknown") + + def test_terms_v0_1_match_published_parameters(self): + terms = load_terms(ROOT) + self.assertEqual(Decimal("0.20"), terms["markup_rate"]) + self.assertEqual(Decimal("0.00"), terms["railiance_self_markup_rate"]) + self.assertEqual(10, terms["payment_term_days"]) + self.assertEqual(Decimal("0.05"), terms["interest_rate_per_year"]) + self.assertEqual(Decimal("1000.00"), terms["default_credit_limit_eur"]) + self.assertEqual(Decimal("50.00"), terms["restricted_monthly_consumption_eur"]) + self.assertEqual("entity:railiance", terms["procuring_entity_id"]) + self.assertEqual(V0_1_PARAMETERS, {key: terms[key] for key in V0_1_PARAMETERS}) + + def test_wrong_markup_fails_closed(self): + payload = json.loads((ROOT / "data/terms/procurement-v0.1.json").read_text()) + payload["markup_rate"] = "0.25" + with self.assertRaisesRegex(ValueError, "markup_rate"): + validate_terms(payload) + + def test_extra_entity_fails_closed(self): + payload = json.loads((ROOT / "data/entities/register.json").read_text()) + payload["entities"].append({ + "id": "entity:other", "display_name": "Other", "role": "consumer", + "credit_limit_eur": "1000.00", "account_ref": None, "status": "active", + }) + with self.assertRaisesRegex(ValueError, "unknown ids"): + validate_register(payload) + + +class AssociationTest(unittest.TestCase): + def test_untagged_record_is_rejected(self): + with self.assertRaisesRegex(ValueError, "untagged record"): + association_ok({"procuring_entity_id": "entity:railiance"}) + + def test_explicit_gap_is_accepted(self): + association_ok({ + "financial_entity_id": None, + "procuring_entity_id": "entity:railiance", + "entity_gap": "no allocation driver", + }) + + def test_each_registered_entity_is_accepted(self): + for entity_id in REQUIRED_ENTITY_IDS: + association_ok({ + "financial_entity_id": entity_id, + "procuring_entity_id": "entity:railiance", + "entity_gap": None, + }) + + def test_inventory_records_are_associated(self): + for path in (ROOT / "data/resources").glob("*.json"): + record = json.loads(path.read_text()) + with self.subTest(path=path): + validate_record(record) + + def test_missing_entity_on_a_copy_is_rejected(self): + record = deepcopy(json.loads(next((ROOT / "data/resources").glob("*.json")).read_text())) + record["financial_entity_id"] = None + record["entity_gap"] = None + with self.assertRaisesRegex(ValueError, "untagged record"): + validate_record(record) diff --git a/tests/test_portfolio_report.py b/tests/test_portfolio_report.py index 27a2a27..b7d8d2d 100644 --- a/tests/test_portfolio_report.py +++ b/tests/test_portfolio_report.py @@ -4,6 +4,7 @@ from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1] / "tools")) +from entities import REQUIRED_ENTITY_IDS from portfolio_report import ( build, cost_section, @@ -158,6 +159,17 @@ class LivePortfolioTest(unittest.TestCase): def test_report_is_deterministic_for_a_fixed_date(self): self.assertEqual(self.report, build(ROOT, TODAY)) + def test_every_financial_entity_is_listed_and_unattributed_cost_is_named(self): + ids = [row["financial_entity_id"] for row in self.report["entities"]["entities"]] + self.assertEqual(list(REQUIRED_ENTITY_IDS), ids) + self.assertIsNone(self.report["entities"]["known_monthly_spend_eur"]) + unattributed = {row["resource_id"] for row in self.report["entities"]["unattributed_resources"]} + self.assertIn("resource:hosteurope:railiance01", unattributed) + coulomb = next(row for row in self.report["entities"]["entities"] if row["financial_entity_id"] == "entity:coulomb") + self.assertIn("resource:tenant:coulomb:coulomb-social", coulomb["dedicated_resources"]) + self.assertTrue(coulomb["shared_shares"]) + self.assertIsNone(coulomb["consumption_mode"]) + class DeliveredEvidenceTest(unittest.TestCase): """RAILIANCE-WP-0016 delivered apps-pg evidence; partial delivery must not diff --git a/tests/test_settlement.py b/tests/test_settlement.py new file mode 100644 index 0000000..ac6b10a --- /dev/null +++ b/tests/test_settlement.py @@ -0,0 +1,116 @@ +import json +import sys +import unittest +from datetime import date +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from entities import load_register, load_terms +from financial_exchange import money +from settlement import ( + apply_payment, + close_fixture, + close_live, + due_date, + evaluate_credit, + monthly_interest, + transfer_price, +) + + +class ArithmeticTest(unittest.TestCase): + def setUp(self): + self.terms = load_terms(ROOT) + _, self.register = load_register(ROOT) + + def test_coulomb_ordinary_month_section_15_1(self): + price = transfer_price(money("200.00"), "entity:coulomb", self.terms) + self.assertEqual(money("240.00"), price) + self.assertEqual(date(2026, 9, 11), due_date(date(2026, 9, 1), self.terms)) + credit = evaluate_credit( + money("240.00"), self.terms, entity_id="entity:coulomb", + register=self.register, overdue=False, + ) + self.assertEqual("open", credit["consumption_mode"]) + self.assertEqual("760.00", credit["credit_headroom_eur"]) + self.assertIsNone(credit["new_transfer_charges_allowed_eur"]) + + def test_unpaid_statement_interest_section_15_2(self): + self.assertEqual(money("1.00"), monthly_interest(money("240.00"), self.terms)) + + def test_helixforge_at_default_limit_section_15_3(self): + credit = evaluate_credit( + money("1000.00"), self.terms, entity_id="entity:helixforge", + register=self.register, overdue=True, + ) + self.assertEqual("restricted", credit["consumption_mode"]) + self.assertEqual("4.17", credit["interest_this_month_eur"]) + self.assertEqual("45.83", credit["new_transfer_charges_allowed_eur"]) + self.assertEqual("38.19", credit["max_new_delivered_cost_eur"]) + + def test_railiance_self_use_has_no_markup(self): + self.assertEqual( + money("200.00"), + transfer_price(money("200.00"), "entity:railiance", self.terms), + ) + + def test_unknown_delivered_cost_stays_null(self): + self.assertIsNone(transfer_price(None, "entity:coulomb", self.terms)) + + def test_payment_clears_interest_before_principal(self): + applied = apply_payment(money("1.00"), money("240.00"), money("200.00")) + self.assertEqual(money("1.00"), applied["interest_paid"]) + self.assertEqual(money("199.00"), applied["principal_paid"]) + self.assertEqual(money("41.00"), applied["principal_remaining"]) + self.assertEqual(money("0.00"), applied["interest_remaining"]) + + +class StatementFixtureTest(unittest.TestCase): + def setUp(self): + self.terms = load_terms(ROOT) + _, self.register = load_register(ROOT) + + def _close(self, name): + payload = json.loads((ROOT / "examples/settlement" / name).read_text()) + return close_fixture(payload, self.terms, self.register) + + def test_ordinary_statement(self): + statements = self._close("15.1-ordinary.json") + self.assertEqual(1, len(statements)) + statement = statements[0] + self.assertEqual("entity:coulomb", statement["financial_entity_id"]) + self.assertEqual("240.00", statement["known_transfer_price_eur"]) + self.assertEqual("2026-09-11", statement["due_date"]) + self.assertEqual("760.00", statement["credit_headroom_eur"]) + self.assertEqual("open", statement["consumption_mode"]) + self.assertEqual("unknown", statement["payment_recognition"]) + + def test_extended_payment_statement(self): + statements = self._close("15.2-extended.json") + statement = statements[0] + self.assertEqual("1.00", statement["interest_eur"]) + self.assertEqual("120.00", statement["new_transfer_charges_eur"]) + self.assertEqual("361.00", statement["outstanding_eur"]) + self.assertEqual("open", statement["consumption_mode"]) + + def test_restricted_statement(self): + statements = self._close("15.3-restricted.json") + statement = statements[0] + self.assertEqual("entity:helixforge", statement["financial_entity_id"]) + self.assertEqual("4.17", statement["interest_eur"]) + self.assertEqual("restricted", statement["consumption_mode"]) + self.assertEqual("45.83", statement["next_month_allowance_eur"]) + + def test_empty_period_emits_nothing(self): + self.assertEqual([], close_live(ROOT, "2026-08")) + + +class PlanningForecastTest(unittest.TestCase): + def test_forecast_fixture_carries_transfer_price_and_headroom(self): + record = json.loads((ROOT / "examples/planning/entity-forecast-transfer.json").read_text()) + self.assertEqual("entity:coulomb", record["financial_entity_id"]) + self.assertEqual("240.00", record["transfer_price"]) + self.assertEqual("760.00", record["credit_headroom"]) + self.assertIn("not booked spend", record["uncertainty"]) diff --git a/tools/entities.py b/tools/entities.py new file mode 100644 index 0000000..6ce1edd --- /dev/null +++ b/tools/entities.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Financial entity register and Terms V0.1 parameter loader.""" + +from __future__ import annotations + +import json +import re +import sys +from decimal import Decimal +from pathlib import Path + +ENTITY_ID = re.compile(r"^entity:[a-z0-9]+$") +RAILIANCE = "entity:railiance" +REQUIRED_ENTITY_IDS = ( + "entity:binky", + "entity:frontier", + "entity:railiance", + "entity:netkingdom", + "entity:helixforge", + "entity:coulomb", +) +# Terms § 14 — tools must load these from data/terms, then assert this contract. +V0_1_PARAMETERS = { + "terms_version": "0.1", + "currency": "EUR", + "markup_rate": Decimal("0.20"), + "railiance_self_markup_rate": Decimal("0.00"), + "settlement_period": "calendar_month", + "payment_term_days": 10, + "interest_rate_per_year": Decimal("0.05"), + "interest_convention": "simple_monthly", + "default_credit_limit_eur": Decimal("1000.00"), + "restricted_monthly_consumption_eur": Decimal("50.00"), + "procuring_entity_id": RAILIANCE, + "labor_rate_eur_per_hour": Decimal("60.00"), +} + + +def default_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text()) + + +def validate_register(register: dict) -> dict[str, dict]: + if register.get("schema_version") != "0.1": + raise ValueError("entity register must use schema_version 0.1") + entities = register.get("entities") or [] + by_id: dict[str, dict] = {} + for entity in entities: + entity_id = entity.get("id", "") + if not ENTITY_ID.fullmatch(entity_id): + raise ValueError(f"invalid entity id: {entity_id}") + if entity_id in by_id: + raise ValueError(f"duplicate entity id: {entity_id}") + by_id[entity_id] = entity + missing = [entity_id for entity_id in REQUIRED_ENTITY_IDS if entity_id not in by_id] + if missing: + raise ValueError(f"entity register missing required ids: {missing}") + extra = sorted(set(by_id) - set(REQUIRED_ENTITY_IDS)) + if extra: + raise ValueError(f"entity register has unknown ids: {extra}") + if by_id[RAILIANCE]["role"] != "provider": + raise ValueError("entity:railiance must have role provider") + if by_id[RAILIANCE]["credit_limit_eur"] is not None: + raise ValueError("entity:railiance has no credit limit under these terms") + return by_id + + +def require_entity(entity_id: str | None, entities: dict[str, dict] | None = None) -> str: + if not entity_id or not ENTITY_ID.fullmatch(entity_id): + raise ValueError(f"unknown entity id: {entity_id}") + known = entities if entities is not None else {item: {} for item in REQUIRED_ENTITY_IDS} + if entity_id not in known: + raise ValueError(f"unknown entity id: {entity_id}") + return entity_id + + +def validate_terms(terms: dict) -> dict: + if terms.get("schema_version") != "0.1": + raise ValueError("procurement terms must use schema_version 0.1") + if terms.get("terms_version") != V0_1_PARAMETERS["terms_version"]: + raise ValueError("tools currently accept only terms_version 0.1") + parsed = { + "terms_version": terms["terms_version"], + "currency": terms["currency"], + "markup_rate": Decimal(terms["markup_rate"]), + "railiance_self_markup_rate": Decimal(terms["railiance_self_markup_rate"]), + "settlement_period": terms["settlement_period"], + "payment_term_days": int(terms["payment_term_days"]), + "interest_rate_per_year": Decimal(terms["interest_rate_per_year"]), + "interest_convention": terms["interest_convention"], + "default_credit_limit_eur": Decimal(terms["default_credit_limit_eur"]), + "restricted_monthly_consumption_eur": Decimal(terms["restricted_monthly_consumption_eur"]), + "procuring_entity_id": terms["procuring_entity_id"], + "labor_rate_eur_per_hour": Decimal(terms["labor_rate_eur_per_hour"]), + } + for key, expected in V0_1_PARAMETERS.items(): + if parsed[key] != expected: + raise ValueError(f"terms parameter {key} is {parsed[key]!r}, expected {expected!r}") + return parsed + + +def load_register(root: Path | None = None) -> tuple[dict, dict[str, dict]]: + root = root or default_root() + payload = load_json(root / "data" / "entities" / "register.json") + return payload, validate_register(payload) + + +def load_terms(root: Path | None = None) -> dict: + root = root or default_root() + return validate_terms(load_json(root / "data" / "terms" / "procurement-v0.1.json")) + + +def association_ok(record: dict) -> None: + """A cost-bearing record needs a consuming entity or an explicit gap.""" + entity_id = record.get("financial_entity_id") + gap = record.get("entity_gap") + procuring = record.get("procuring_entity_id") + if not procuring: + raise ValueError("cost-bearing records require procuring_entity_id") + require_entity(procuring) + if entity_id: + require_entity(entity_id) + if gap: + raise ValueError("financial_entity_id and entity_gap cannot both be set") + return + if not gap: + raise ValueError("untagged record needs financial_entity_id or an explicit entity_gap") + + +def main() -> int: + root = Path(sys.argv[1]) if len(sys.argv) > 1 else default_root() + _, entities = load_register(root) + terms = load_terms(root) + print(f"entities: {len(entities)}") + print(f"terms_version: {terms['terms_version']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/portfolio.py b/tools/portfolio.py index ea81c44..0a71f84 100644 --- a/tools/portfolio.py +++ b/tools/portfolio.py @@ -9,6 +9,8 @@ import sys from datetime import date from pathlib import Path +from entities import association_ok + RESOURCE_ID = re.compile(r"^resource:[a-z0-9][a-z0-9:_-]+$") STATUSES = { "proposed", "ordered", "commissioning", "active", "suspended", @@ -47,6 +49,8 @@ def validate_record(record: dict) -> None: if record.get("record_scope") not in {"inventory", "example"}: raise ValueError("record_scope must be inventory or example") + association_ok(record) + allocation = record["ownership"]["allocation"] if allocation["mode"] == "unattributed": if allocation["cost_attribution_key"] is not None: diff --git a/tools/portfolio_report.py b/tools/portfolio_report.py index d7e7459..584a5a7 100644 --- a/tools/portfolio_report.py +++ b/tools/portfolio_report.py @@ -13,6 +13,7 @@ import sys from datetime import date from pathlib import Path +from entities import REQUIRED_ENTITY_IDS, load_register from optimization import evaluate, validate_case from portfolio import validate_record @@ -260,6 +261,47 @@ def optimization_section(cases: list[dict]) -> dict: } +def entities_section(resources: list[dict], register: dict[str, dict]) -> dict: + by_id = {entity_id: { + "financial_entity_id": entity_id, + "display_name": register[entity_id]["display_name"], + "role": register[entity_id]["role"], + "dedicated_resources": [], + "shared_shares": [], + "priced_resources": 0, + "consumption_mode": None, + "consumption_mode_note": "no settlement close yet; mode is unknown, not open", + } for entity_id in REQUIRED_ENTITY_IDS} + unattributed = [] + for resource in resources: + entity_id = resource.get("financial_entity_id") + entry = { + "resource_id": resource["id"], + "price_evidence": bool(resource["cost"]["price_evidence"]), + } + if entity_id: + by_id[entity_id]["dedicated_resources"].append(resource["id"]) + if resource["cost"]["price_evidence"]: + by_id[entity_id]["priced_resources"] += 1 + else: + unattributed.append({ + "resource_id": resource["id"], + "entity_gap": resource.get("entity_gap"), + "allocation_mode": resource["ownership"]["allocation"]["mode"], + }) + for share in resource["ownership"]["allocation"].get("entity_shares") or []: + share_id = share["financial_entity_id"] + by_id[share_id]["shared_shares"].append({ + "resource_id": resource["id"], + "note": share["note"], + }) + return { + "entities": [by_id[entity_id] for entity_id in REQUIRED_ENTITY_IDS], + "unattributed_resources": unattributed, + "known_monthly_spend_eur": None, + } + + def next_actions(report: dict) -> list[str]: """The smallest set of evidence that would unblock the most decisions.""" actions = [] @@ -281,6 +323,7 @@ def next_actions(report: dict) -> list[str]: def build(root: Path, today: date | None = None) -> dict: today = today or date.today() data = load_portfolio(root) + _, register = load_register(root) resources = data["resources"] utilization = utilization_section(resources) cost = cost_section(resources) @@ -292,6 +335,7 @@ def build(root: Path, today: date | None = None) -> dict: "lifecycle": lifecycle_section(resources), "utilization": utilization, "cost": cost, + "entities": entities_section(resources, register), "renewals": renewals_section(resources, today), "optimization": optimization_section(data["cases"]), } diff --git a/tools/settlement.py b/tools/settlement.py new file mode 100644 index 0000000..f0cb521 --- /dev/null +++ b/tools/settlement.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Delivered-cost transfer prices, credit state, and monthly settlement.""" + +from __future__ import annotations + +import argparse +import json +import sys +from calendar import monthrange +from datetime import date, timedelta +from decimal import Decimal +from pathlib import Path + +from entities import RAILIANCE, association_ok, load_register, load_terms, require_entity +from financial_exchange import money, money_text + +ROOT = Path(__file__).resolve().parents[1] + + +def transfer_price(delivered_cost: Decimal | None, consuming_entity: str, terms: dict) -> Decimal | None: + if delivered_cost is None: + return None + require_entity(consuming_entity) + rate = terms["railiance_self_markup_rate"] if consuming_entity == RAILIANCE else terms["markup_rate"] + return money(delivered_cost * (Decimal("1") + rate)) + + +def max_new_delivered_cost(allowance: Decimal | None, consuming_entity: str, terms: dict) -> Decimal | None: + if allowance is None: + return None + if consuming_entity == RAILIANCE or allowance == Decimal("0.00"): + return money(allowance) if consuming_entity == RAILIANCE else money(0) + divisor = Decimal("1") + terms["markup_rate"] + return money(allowance / divisor) + + +def due_date(statement_date: date, terms: dict) -> date: + return statement_date + timedelta(days=terms["payment_term_days"]) + + +def monthly_interest(overdue: Decimal, terms: dict) -> Decimal: + if overdue <= 0: + return money(0) + return money(overdue * terms["interest_rate_per_year"] / Decimal(12)) + + +def apply_payment(interest_due: Decimal, principal: Decimal, payment: Decimal) -> dict: + """Interest first, then principal (Terms OQ-7).""" + interest_due = money(interest_due) + principal = money(principal) + remaining = money(payment) + interest_paid = money(min(interest_due, remaining)) + remaining = money(remaining - interest_paid) + principal_paid = money(min(principal, remaining)) + remaining = money(remaining - principal_paid) + return { + "interest_paid": interest_paid, + "principal_paid": principal_paid, + "interest_remaining": money(interest_due - interest_paid), + "principal_remaining": money(principal - principal_paid), + "unapplied": remaining, + } + + +def credit_limit_for(entity_id: str, terms: dict, register: dict[str, dict]) -> Decimal | None: + require_entity(entity_id, register) + if entity_id == RAILIANCE: + return None + raw = register[entity_id].get("credit_limit_eur") + if raw is None: + return terms["default_credit_limit_eur"] + return money(raw) + + +def evaluate_credit(outstanding: Decimal, terms: dict, *, entity_id: str, register: dict[str, dict], overdue: bool) -> dict: + outstanding = money(outstanding) + limit = credit_limit_for(entity_id, terms, register) + interest = monthly_interest(outstanding, terms) if overdue else money(0) + if limit is None: + return { + "consumption_mode": "open", + "credit_limit_eur": None, + "credit_headroom_eur": None, + "interest_this_month_eur": money_text(interest), + "new_transfer_charges_allowed_eur": None, + "max_new_delivered_cost_eur": None, + } + restricted = outstanding >= limit + if restricted: + allowance = money(max(Decimal("0.00"), terms["restricted_monthly_consumption_eur"] - interest)) + else: + allowance = None + headroom = money(max(Decimal("0.00"), limit - outstanding)) + return { + "consumption_mode": "restricted" if restricted else "open", + "credit_limit_eur": money_text(limit), + "credit_headroom_eur": money_text(headroom), + "interest_this_month_eur": money_text(interest), + "new_transfer_charges_allowed_eur": None if allowance is None else money_text(allowance), + "max_new_delivered_cost_eur": ( + None if allowance is None else money_text(max_new_delivered_cost(allowance, entity_id, terms)) + ), + } + + +def statement_date_for(period: str) -> date: + year, month = (int(part) for part in period.split("-")) + if month == 12: + return date(year + 1, 1, 1) + return date(year, month + 1, 1) + + +def _line_item(charge: dict, terms: dict) -> dict: + entity_id = charge["financial_entity_id"] + delivered = None if charge.get("delivered_cost") is None else money(charge["delivered_cost"]) + price = transfer_price(delivered, entity_id, terms) + markup = None + if delivered is not None and price is not None: + markup = money(price - delivered) + components = charge.get("components") or {} + return { + "resource_id": charge["resource_id"], + "usage_summary": charge.get("usage_summary") or "", + "delivered_cost_eur": None if delivered is None else money_text(delivered), + "components": { + "infrastructure": components.get("infrastructure"), + "internal_labor": components.get("internal_labor"), + "external_services": components.get("external_services"), + "setup": components.get("setup"), + "other": components.get("other"), + }, + "markup_eur": None if markup is None else money_text(markup), + "transfer_price_eur": None if price is None else money_text(price), + "unknown_remainder": charge.get("unknown_remainder"), + } + + +def build_statement( + *, + entity_id: str, + period: str, + terms: dict, + register: dict[str, dict], + statement_date: date, + lines: list[dict], + prior_outstanding: Decimal, + recognized_payments: list[dict], + as_of: date | None = None, + payment_status: str = "unknown", +) -> dict | None: + require_entity(entity_id, register) + as_of = as_of or statement_date + due = due_date(statement_date, terms) + prior = money(prior_outstanding) + paid = money(0) + for payment in recognized_payments: + if payment.get("financial_entity_id") == entity_id: + paid = money(paid + money(payment["amount"])) + payment_status = "recognized" + known_cost = money(0) + known_transfer = money(0) + unknown = False + for line in lines: + if line["delivered_cost_eur"] is None or line["transfer_price_eur"] is None: + unknown = True + else: + known_cost = money(known_cost + money(line["delivered_cost_eur"])) + known_transfer = money(known_transfer + money(line["transfer_price_eur"])) + prior_due = due_date(statement_date_for(_previous_period(period)), terms) if prior > 0 else due + # Interest is quantified when prior outstanding is past its due date at as_of. + overdue = prior > 0 and as_of > prior_due + if prior > 0 and as_of == statement_date and statement_date <= due: + # Brand-new statement of this period: prior from last period is overdue + # only if its own due date has passed. + overdue = as_of > prior_due + interest = monthly_interest(prior, terms) if overdue else money(0) + applied = apply_payment(interest, prior, paid) + balance_before_interest = money(applied["principal_remaining"] + known_transfer) + outstanding = money(balance_before_interest + applied["interest_remaining"]) + credit = evaluate_credit( + balance_before_interest, terms, entity_id=entity_id, register=register, overdue=overdue, + ) + has_activity = bool(lines) or prior > 0 or paid > 0 or interest > 0 + if not has_activity: + return None + entity = register[entity_id] + provider = register[RAILIANCE] + payment_instruction = None + if entity_id != RAILIANCE and known_transfer > 0: + payment_instruction = { + "pay_from_account_ref": entity.get("account_ref"), + "pay_to_account_ref": provider.get("account_ref"), + } + return { + "schema_version": "0.1", + "record_type": "settlement_statement", + "terms_version": terms["terms_version"], + "financial_entity_id": entity_id, + "procuring_entity_id": RAILIANCE, + "period": period, + "statement_date": statement_date.isoformat(), + "due_date": due.isoformat(), + "currency": terms["currency"], + "line_items": lines, + "known_delivered_cost_eur": money_text(known_cost), + "known_transfer_price_eur": money_text(known_transfer), + "unknown_cost_remainder": unknown, + "prior_outstanding_eur": money_text(prior), + "recognized_payments_eur": None if payment_status == "unknown" and paid == 0 else money_text(paid), + "payment_recognition": payment_status if paid > 0 else "unknown", + "interest_eur": money_text(interest), + "new_transfer_charges_eur": money_text(known_transfer), + "outstanding_eur": money_text(outstanding), + "credit_limit_eur": credit["credit_limit_eur"], + "credit_headroom_eur": credit["credit_headroom_eur"], + "consumption_mode": credit["consumption_mode"], + "next_month_allowance_eur": credit["new_transfer_charges_allowed_eur"], + "payment_instruction": payment_instruction, + } + + +def _previous_period(period: str) -> str: + year, month = (int(part) for part in period.split("-")) + if month == 1: + return f"{year - 1}-12" + return f"{year}-{month - 1:02d}" + + +def close_fixture(payload: dict, terms: dict, register: dict[str, dict]) -> list[dict]: + statement_date = date.fromisoformat(payload["statement_date"]) + as_of = date.fromisoformat(payload["as_of"]) if payload.get("as_of") else statement_date + by_entity: dict[str, list[dict]] = {} + for charge in payload.get("charges", []): + association_ok({ + "financial_entity_id": charge["financial_entity_id"], + "procuring_entity_id": charge.get("procuring_entity_id", RAILIANCE), + "entity_gap": None, + }) + by_entity.setdefault(charge["financial_entity_id"], []).append(_line_item(charge, terms)) + prior = {key: money(value) for key, value in (payload.get("prior_outstanding") or {}).items()} + entities = sorted(set(by_entity) | set(prior) | { + payment["financial_entity_id"] for payment in payload.get("recognized_payments") or [] + }) + statements = [] + for entity_id in entities: + statement = build_statement( + entity_id=entity_id, + period=payload["period"], + terms=terms, + register=register, + statement_date=statement_date, + lines=by_entity.get(entity_id, []), + prior_outstanding=prior.get(entity_id, money(0)), + recognized_payments=payload.get("recognized_payments") or [], + as_of=as_of, + payment_status=payload.get("payment_recognition", "unknown"), + ) + if statement: + statements.append(statement) + return statements + + +def collect_live_charges(root: Path, period: str) -> list[dict]: + """Live close only emits a charge when delivered cost is known. Unknown is not zero.""" + charges = [] + year, month = (int(part) for part in period.split("-")) + period_end = date(year, month, monthrange(year, month)[1]) + for path in sorted((root / "data" / "resources").glob("*.json")): + resource = json.loads(path.read_text()) + if resource.get("status") in {"rejected", "retired"}: + continue + commissioned = resource.get("lifecycle", {}).get("commissioned_on") + if commissioned and date.fromisoformat(commissioned) > period_end: + continue + proposed = resource.get("lifecycle", {}).get("proposed_on") + if resource.get("status") == "proposed" and proposed and date.fromisoformat(proposed) > period_end: + continue + # No booked delivered cost lives on inventory records today. + # A control-cycle actual with known infrastructure would be the source; + # until then there are no live charges. + del path + control_dir = root / "data" / "control-cycle" + if control_dir.exists(): + for path in sorted(control_dir.glob("*.json")): + record = json.loads(path.read_text()) + if record.get("period") != period: + continue + costs = record.get("costs") or {} + parts = [costs.get("infrastructure"), costs.get("internal_labor"), costs.get("external_labor")] + if any(part is None for part in parts): + continue + entity_id = record.get("financial_entity_id") + if not entity_id: + continue + charges.append({ + "financial_entity_id": entity_id, + "procuring_entity_id": record.get("procuring_entity_id", RAILIANCE), + "resource_id": record["resource_id"], + "delivered_cost": money_text(sum(parts)), + "usage_summary": f"control-cycle {record['record_type']} {record['record_id']}", + "components": { + "infrastructure": None if costs.get("infrastructure") is None else money_text(costs["infrastructure"]), + "internal_labor": None if costs.get("internal_labor") is None else money_text(costs["internal_labor"]), + "external_services": None if costs.get("external_labor") is None else money_text(costs["external_labor"]), + "setup": None, + "other": None, + }, + }) + return charges + + +def close_live(root: Path, period: str) -> list[dict]: + terms = load_terms(root) + _, register = load_register(root) + payload = { + "period": period, + "statement_date": statement_date_for(period).isoformat(), + "charges": collect_live_charges(root, period), + "prior_outstanding": {}, + "recognized_payments": [], + "payment_recognition": "unknown", + } + return close_fixture(payload, terms, register) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Monthly settlement close") + parser.add_argument("command", choices=["close"]) + parser.add_argument("--period", help="YYYY-MM") + parser.add_argument("--fixture", help="path to a settlement fixture") + parser.add_argument("--root", default=str(ROOT)) + args = parser.parse_args() + root = Path(args.root) + terms = load_terms(root) + _, register = load_register(root) + if args.fixture: + payload = json.loads(Path(args.fixture).read_text()) + statements = close_fixture(payload, terms, register) + else: + if not args.period: + print("PERIOD=YYYY-MM is required without --fixture", file=sys.stderr) + return 2 + statements = close_live(root, args.period) + print(json.dumps(statements, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate.py b/tools/validate.py index 829243b..e273b71 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -4,9 +4,11 @@ import json from pathlib import Path +from entities import REQUIRED_ENTITY_IDS, load_register, load_terms, require_entity, validate_register, validate_terms from optimization import validate_case from portfolio import validate_record from portfolio_report import build as build_portfolio_report +from settlement import close_fixture, close_live def load(path: str) -> dict: @@ -85,6 +87,33 @@ def main() -> int: assert report["resource_count"] == len(resource_paths) - len(list(Path("examples/portfolio").glob("*.json"))) assert report["cost"]["known_monthly_spend_eur"] is None assert report["next_actions"] + register_payload = load("data/entities/register.json") + terms_payload = load("data/terms/procurement-v0.1.json") + entities = validate_register(register_payload) + terms = validate_terms(terms_payload) + _, loaded_entities = load_register(Path(".")) + assert loaded_entities.keys() == entities.keys() + assert list(entities) == list(REQUIRED_ENTITY_IDS) or set(entities) == set(REQUIRED_ENTITY_IDS) + assert terms["markup_rate"] == load_terms(Path("."))["markup_rate"] + try: + require_entity("entity:unknown") + raise AssertionError("unknown entity id must be rejected") + except ValueError as exc: + assert "unknown entity id" in str(exc) + entity_ids = {row["financial_entity_id"] for row in report["entities"]["entities"]} + assert entity_ids == set(REQUIRED_ENTITY_IDS) + assert report["entities"]["known_monthly_spend_eur"] is None + assert report["entities"]["unattributed_resources"] + live_statements = close_live(Path("."), "2026-08") + assert live_statements == [] + fixture_dir = Path("examples/settlement") + ordinary = close_fixture(load(str(fixture_dir / "15.1-ordinary.json")), terms, entities) + assert len(ordinary) == 1 + assert ordinary[0]["known_transfer_price_eur"] == "240.00" + planning = load("examples/planning/entity-forecast-transfer.json") + assert planning["transfer_price"] == "240.00" + assert planning["credit_headroom"] == "760.00" + assert planning["financial_entity_id"] == "entity:coulomb" print("resource-control declarations: valid") return 0 diff --git a/workplans/RESOURCE-WP-0005-resource-procurement-facility.md b/workplans/RESOURCE-WP-0005-resource-procurement-facility.md index cd7427e..5bd22a5 100644 --- a/workplans/RESOURCE-WP-0005-resource-procurement-facility.md +++ b/workplans/RESOURCE-WP-0005-resource-procurement-facility.md @@ -4,7 +4,7 @@ type: workplan title: "Build the resource procurement facility" domain: financials repo: resource-control -status: ready +status: finished owner: grok topic_slug: railiance created: "2026-08-14" @@ -13,6 +13,8 @@ related: - RESOURCE-WP-0003 - RESOURCE-WP-0004 - FIN-WP-0004 + - FIN-WP-0006 + - RAILIANCE-WP-0017 origin: residual origin_ref: RESOURCE-WP-0004 state_hub_workstream_id: "0c710416-e469-45a7-b007-d08ff3210c04" @@ -78,7 +80,7 @@ The three worked examples in Terms § 15 are executable fixtures. ```task id: RESOURCE-WP-0005-T01 -status: todo +status: done priority: high state_hub_task_id: "f8df895b-9959-4bb2-958e-f2444b558aac" ``` @@ -103,11 +105,15 @@ Done when `make test` loads the register and the V0.1 parameter file, rejects an unknown `entity:` id, and the numeric parameters match Terms § 14 exactly. +Completed 2026-08-14: `data/entities/register.json`, +`data/terms/procurement-v0.1.json`, schemas, and `tools/entities.py`. +Unknown ids and drifted parameters fail closed. + ## T02 — Associate every cost-bearing schema ```task id: RESOURCE-WP-0005-T02 -status: todo +status: done priority: high state_hub_task_id: "5a18a74a-db07-4ab6-8bb5-ed4321ab7e11" ``` @@ -132,11 +138,14 @@ Done when validators reject an untagged new record, accept an explicit gap, accept all six entity IDs, and existing tests still pass after additive schema changes. +Completed 2026-08-14: additive fields on inventory, planning-evidence, +and control-cycle; `portfolio.validate_record` requires association. + ## T03 — Backfill the live portfolio ```task id: RESOURCE-WP-0005-T03 -status: todo +status: done priority: high state_hub_task_id: "14d450fb-e1f2-4443-a284-4439579f3709" ``` @@ -159,11 +168,15 @@ Done when every live inventory record has a procuring entity and either a consuming entity, a shared allocation that yields per-entity shares, or an explicit unattributed gap, and `make test` passes. +Completed 2026-08-14: Coulomb, Binky, and platform backup tagged; +apps-pg shares Coulomb; host/cluster/forge remain explicit Railiance +residuals; no helix-forge dedicated resource exists yet. + ## T04 — Transfer-price and credit-state engine ```task id: RESOURCE-WP-0005-T04 -status: todo +status: done priority: high state_hub_task_id: "4d2c87f7-6e50-4696-a041-e8502e9fab38" ``` @@ -197,11 +210,14 @@ Done when fixtures for Terms § 15.1–15.3 assert: - Helixforge at 1000.00 overdue: interest 4.17, allowance 45.83, max new delivered cost 38.19, mode `restricted`. +Completed 2026-08-14: `tools/settlement.py` loads terms from data. +Fixtures assert 240.00 / 1.00 / 4.17 / 45.83 / 38.19. + ## T05 — Monthly settlement statements ```task id: RESOURCE-WP-0005-T05 -status: todo +status: done priority: high state_hub_task_id: "cf55645b-b3ef-4cc4-ab51-23bdf5ff3af1" ``` @@ -230,11 +246,14 @@ Done when the three § 15 scenarios can be produced as statements from fixtures, `make settlement` runs against the repo, and a period with no consumers emits nothing rather than invented zeros. +Completed 2026-08-14: statement schema, `make settlement PERIOD=YYYY-MM`, +§ 15 fixtures emit statements, live 2026-08 emits `[]`. + ## T06 — Entity views on forecasts, variance, and the portfolio report ```task id: RESOURCE-WP-0005-T06 -status: todo +status: done priority: medium state_hub_task_id: "b8baa250-f189-4ae7-a4a6-5b09a199c1dd" ``` @@ -253,11 +272,15 @@ Done when `make portfolio-report` lists each financial entity, names unattributed resources, and a forecast fixture includes transfer price and headroom without becoming booked spend. +Completed 2026-08-14: report `entities` section lists all six IDs and +names unattributed residuals; forecast fixture carries 240.00 / 760.00 +and is labelled not booked spend. + ## T07 — Fin-hub and Railiance handoff, plus the close runbook ```task id: RESOURCE-WP-0005-T07 -status: todo +status: done priority: medium state_hub_task_id: "d91a2ffd-7557-4a9a-a846-e6449b037eec" ``` @@ -283,3 +306,7 @@ Done when the runbook exists, the outbound artifact shapes are documented next to the fin-hub contract, and live work records exist in the owning repos (or an intake with `origin: residual` if those repos cannot take the work this session). + +Completed 2026-08-14: `docs/resource-procurement-facility.md`; contract +addendum for settlement join keys; residuals `FIN-WP-0006` and +`RAILIANCE-WP-0017`.