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.
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
#!/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())
|