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:
parent
325a505980
commit
f8d1c542d5
37 changed files with 1316 additions and 28 deletions
6
Makefile
6
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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
54
data/entities/register.json
Normal file
54
data/entities/register.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
15
data/terms/procurement-v0.1.json
Normal file
15
data/terms/procurement-v0.1.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
88
docs/resource-procurement-facility.md
Normal file
88
docs/resource-procurement-facility.md
Normal 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`.
|
||||
41
examples/planning/entity-forecast-transfer.json
Normal file
41
examples/planning/entity-forecast-transfer.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
17
examples/settlement/15.1-ordinary.json
Normal file
17
examples/settlement/15.1-ordinary.json
Normal 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": []
|
||||
}
|
||||
19
examples/settlement/15.2-extended.json
Normal file
19
examples/settlement/15.2-extended.json
Normal 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": []
|
||||
}
|
||||
12
examples/settlement/15.3-restricted.json
Normal file
12
examples/settlement/15.3-restricted.json
Normal 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": []
|
||||
}
|
||||
29
schemas/financial-entity-register.schema.json
Normal file
29
schemas/financial-entity-register.schema.json
Normal 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"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}}
|
||||
}
|
||||
|
|
|
|||
37
schemas/procurement-terms.schema.json
Normal file
37
schemas/procurement-terms.schema.json
Normal 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}$"}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
schemas/settlement-statement.schema.json
Normal file
54
schemas/settlement-statement.schema.json
Normal 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
92
tests/test_entities.py
Normal 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)
|
||||
|
|
@ -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
116
tests/test_settlement.py
Normal 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
144
tools/entities.py
Normal 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())
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
349
tools/settlement.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue