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.
This commit is contained in:
tegwick 2026-08-14 13:15:02 +02:00
parent 325a505980
commit f8d1c542d5
37 changed files with 1316 additions and 28 deletions

View file

@ -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)

View file

@ -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 |

View file

@ -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",

View file

@ -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"
}
]
}

View file

@ -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": {

View file

@ -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"},

View file

@ -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"},

View file

@ -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"},

View file

@ -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",

View file

@ -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"},

View file

@ -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"},

View file

@ -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"
}

View file

@ -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

View file

@ -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 |

View file

@ -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`.

View file

@ -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"
]
}

View file

@ -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"},

View file

@ -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"},

View file

@ -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"},

View file

@ -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": []
}

View file

@ -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": []
}

View file

@ -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": []
}

View file

@ -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"]}
}
}
}
}
}

View file

@ -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"}}
}

View file

@ -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}$"}
}
}

View file

@ -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",

View file

@ -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"}
}
}
}
}
}
}

View file

@ -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"]}
}
}

92
tests/test_entities.py Normal file
View file

@ -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)

View file

@ -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

116
tests/test_settlement.py Normal file
View file

@ -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"])

144
tools/entities.py Normal file
View file

@ -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())

View file

@ -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:

View file

@ -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"]),
}

349
tools/settlement.py Normal file
View file

@ -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())

View file

@ -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

View file

@ -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.115.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`.