diff --git a/CHANGELOG.md b/CHANGELOG.md index 279a1e66..0c39199b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — only committed capital owns anything (cap table + waterfall) + +`capital.cap_table` summed `commitment` across every investor whatever their workflow state. A +`prospect` carrying a $10M interest and $0 contributed took 50% of a $10M cap table and halved a real +LP from 60% to 30% — and the number did not stop at display: `distwaterfall` allocates off these rows, +so the prospect drew **$1,818,181.82 of a $2M distribution** while the committed LP got $181,818.18. + +**The obvious filter is wrong on its own.** `investor` declares `initial: prospect` and every record +is stamped with it at creation, so on a project where nobody ran the `commit` transition every +investor is a prospect and filtering empties the cap table. `workflow_in_use` separates the readings: +a default state is not a signal. Until some investor moves off the initial state everyone counts; +after that, `prospect` genuinely means "not committed". Prospect rows stay visible at 0% with their +money reported as `pipeline_commitment`, and no longer sort above real owners. + +The decision rides on each row as `counts_toward_ownership` so the seven consumers cannot disagree. +Mutation-checking found `test_distwaterfall` passed even with the waterfall ignoring the flag — its +fixture has no prospect — so `test_cap_table_state.py` covers that case through the real API. + ## Unreleased — R24-REPORTS-BY-MOMENT: a finished pack can be sent, not only downloaded `POST /projects/{pid}/jobs/{job_id}/deliver` emails any finished job's artifact to named diff --git a/docs/roadmap.md b/docs/roadmap.md index c9d4b679..ff126d37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1359,8 +1359,8 @@ that rotted were all sentences no test read. Note for whoever extends it — the ### Decisions, not effort — these want your call -- **A prospect investor dilutes every real one, and the obvious fix empties the cap table.** - *(measured 2026-08-29; money-bearing, and NOT a filter fix — read the second half before touching it)* +- ✅ **A prospect investor diluted every real one — FIXED 2026-09-04, option (c) with a guard.** + *(measured 2026-08-29; the decision below was the user's, taken 2026-09-04)* `capital.cap_table` sums `commitment` across **every** investor regardless of state. It even reads `workflow_state` — to display as `status` — and never filters on it. Measured against the real @@ -1382,12 +1382,24 @@ that rotted were all sentences no test read. Note for whoever extends it — the distribution — returned **0.0**. Not a stale fixture: that is the product's own default path. A filter would empty the cap table of every project whose investors were never transitioned. - So the question is which signal means "this commitment is real", and it is a domain decision rather - than a code change: (a) make `committed` the initial state, or require the transition before a - record counts — a data migration for existing projects; (b) key the math on `contributed > 0` - instead of state, which changes what a *commitment* means in an uncalled fund; or (c) keep the rows - and exclude them from the denominator, showing prospects at 0% with the pipeline named separately. - Each is defensible and they produce different ownership numbers, which is why this is yours. + **Resolved: (c), plus the guard that makes it safe.** (b) was rejected as a domain error — in an + uncalled fund an LP with a signed commitment and `contributed = 0` is normal, so keying on + contribution zeroes out real LPs who have not been called yet. (a) was rejected as too invasive to + take on the owner's behalf: it needs a data migration and rewires the default entry path. + + (c) alone still walks into the trap above, so `cap_table` now separates two readings of the same + value: **a default state is not a signal.** `workflow_in_use` is true once ANY investor has moved + off the stamped initial state; until then `prospect` means "nobody used the workflow" and every + investor counts, exactly as before. Once one has, `prospect` means "not committed" and the state is + evidence. Prospect rows are never dropped — they stay visible at 0%, their money reported as + `pipeline_commitment`, and they no longer sort above real owners. + + The decision is carried on each row as `counts_toward_ownership` rather than re-derived by each of + the seven consumers, and `distwaterfall` honours it. `services/api/test_cap_table_state.py` pins + both halves; **mutation-checking found that `test_distwaterfall` passed even with `distwaterfall` + ignoring the flag** — its fixture has no prospect — so the missing case is now covered there: + without it a prospect drew **$1,818,181.82 of a $2M distribution** while the committed LP got + $181,818.18. `exited` is treated as evidence the workflow was used but not as current ownership. - **Asset-rights stopped at signing, on purpose, and going further is your call — not effort.** Shipped 2026-08-29: a stable asset identity that survives a `.mass` round-trip, an opt-in release diff --git a/services/api/run_tests.py b/services/api/run_tests.py index 5db23956..cb260624 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/capital.py b/services/api/src/aec_api/capital.py index b990f461..34f13bc4 100644 --- a/services/api/src/aec_api/capital.py +++ b/services/api/src/aec_api/capital.py @@ -16,33 +16,78 @@ def _num(v: Any) -> float: return 0.0 +#: `investor`'s workflow is prospect -> committed -> funded -> exited. Ownership belongs to the two +#: middle states: the `commit` transition is the one that declares `requires: ["commitment"]`, so the +#: amount is only validated at that point, and `rescind` (committed -> prospect) is how a commitment is +#: withdrawn. `exited` is deliberately NOT here — an investor who has left does not hold current +#: ownership — while `prospect` is not yet a commitment, only an interest. +_OWNING_STATES = ("committed", "funded") + + def cap_table(investors: list[dict]) -> dict[str, Any]: - """Ownership by commitment, contributed/distributed/unreturned totals, per-investor rows.""" + """Ownership by commitment, contributed/distributed/unreturned totals, per-investor rows. + + **Only committed capital owns anything, and a DEFAULT STATE IS NOT A SIGNAL.** This summed + `commitment` across every investor whatever their state: one `prospect` carrying a $10M interest + and $0 contributed took 50% of a $10M table and halved a real LP from 60% to 30% — and the number + left here, because `distwaterfall` allocates off these rows. + + The obvious fix — filter out `prospect` — is wrong on its own, and that is the part worth knowing. + `investor` declares `initial: prospect` and every record is stamped with it at creation, so on a + project where nobody ever ran the `commit` transition EVERY investor is a prospect and the filter + empties the cap table. `workflow_in_use` is what separates the two readings: until some investor + has moved off the initial state, `prospect` means "untouched" and carries no information, so + everyone counts; once one has, `prospect` genuinely means "not committed" and the state is + evidence. Prospect rows are never dropped — they stay visible at 0% with their money reported as + `pipeline_commitment`, so the interest is still on screen, just not as ownership. + """ + # Has anyone actually used the workflow on this project? Any state other than the stamped initial + # one proves it, `exited` included — that investor was committed once, which is the same evidence. + workflow_in_use = any( + (i.get("workflow_state") or "prospect") != "prospect" for i in investors) + + def _owns(i: dict) -> bool: + return (not workflow_in_use) or (i.get("workflow_state") in _OWNING_STATES) + rows = [] - total_commit = sum(_num((i.get("data") or i).get("commitment")) for i in investors) + total_commit = sum(_num((i.get("data") or i).get("commitment")) for i in investors if _owns(i)) + pipeline = sum(_num((i.get("data") or i).get("commitment")) + for i in investors if not _owns(i)) for i in investors: d = i.get("data") or i commit = _num(d.get("commitment")) contributed = _num(d.get("contributed")) distributed = _num(d.get("distributed")) + owns = _owns(i) rows.append({ "id": i.get("id"), "ref": i.get("ref"), "investor": d.get("investor"), "investor_class": d.get("investor_class") or "LP", "entity_type": d.get("entity_type"), "commitment": round(commit, 2), - "ownership_pct": round(100 * commit / total_commit, 4) if total_commit else 0.0, + # Carried on the ROW, not recomputed by each consumer: seven call sites read this table, + # and a rule re-derived seven times is a rule that disagrees with itself somewhere. + "counts_toward_ownership": owns, + "ownership_pct": round(100 * commit / total_commit, 4) if (owns and total_commit) else 0.0, "contributed": round(contributed, 2), "distributed": round(distributed, 2), "unreturned": round(max(0.0, contributed - distributed), 2), "status": i.get("workflow_state"), }) - rows.sort(key=lambda r: -r["commitment"]) + # Owners first, then by size. Sorting on commitment alone put the $10M prospect at the TOP of the + # table as the largest apparent owner while showing 0%% — the reader's eye takes rank as ownership. + rows.sort(key=lambda r: (not r["counts_toward_ownership"], -r["commitment"])) by_class: dict[str, float] = {} for r in rows: + if not r["counts_toward_ownership"]: + continue # or by_class would sum to more than total_commitment by_class[r["investor_class"]] = by_class.get(r["investor_class"], 0.0) + r["commitment"] return { "investor_count": len(rows), + # `total_commitment` is COMMITTED capital — the denominator ownership is computed against. + # The uncommitted interest is reported beside it rather than folded in or silently dropped. "total_commitment": round(total_commit, 2), + "pipeline_commitment": round(pipeline, 2), + "workflow_in_use": workflow_in_use, "total_contributed": round(sum(r["contributed"] for r in rows), 2), "total_distributed": round(sum(r["distributed"] for r in rows), 2), "total_unreturned": round(sum(r["unreturned"] for r in rows), 2), diff --git a/services/api/src/aec_api/distwaterfall.py b/services/api/src/aec_api/distwaterfall.py index 2377d053..93c0429c 100644 --- a/services/api/src/aec_api/distwaterfall.py +++ b/services/api/src/aec_api/distwaterfall.py @@ -66,8 +66,12 @@ def scenario(db, pid: str, body: dict | None = None) -> dict[str, Any]: investors = me.list_records(db, "investor", pid, limit=100000) if "investor" in me.TABLES else [] ct = capital.cap_table(investors) rows = ct["rows"] - lp = [r for r in rows if not _is_gp(r["investor_class"])] - gp = [r for r in rows if _is_gp(r["investor_class"])] + # Honour `cap_table`'s ownership rule rather than re-deriving one. A prospect drew a real + # distribution share here — `share = lp_total * (commitment / lp_commit)` off a commitment nobody + # had committed to — because these rows were filtered by CLASS and never by state. + owning = [r for r in rows if r["counts_toward_ownership"]] + lp = [r for r in owning if not _is_gp(r["investor_class"])] + gp = [r for r in owning if _is_gp(r["investor_class"])] lp_commit = sum(r["commitment"] for r in lp) gp_commit = sum(r["commitment"] for r in gp) # no cap table -> nothing to allocate; return a clean zeroed scenario rather than a phantom split diff --git a/services/api/test_cap_table_state.py b/services/api/test_cap_table_state.py new file mode 100644 index 00000000..b4842af8 --- /dev/null +++ b/services/api/test_cap_table_state.py @@ -0,0 +1,118 @@ +"""Only COMMITTED capital owns anything — and a default state is not a signal. + +`cap_table` summed `commitment` across every investor whatever their workflow state. The roadmap +measured it: two funded LPs at $6M and $4M plus one `prospect` carrying a $10M interest and $0 +contributed, and the prospect took 50% of the table, halved Anchor LP from 60% to 30%, and sorted to +the top as the largest apparent owner. It did not stop at display — `distwaterfall` allocates +`share = lp_total * (commitment / lp_commit)` off these rows, so the prospect drew real money. + +THE OBVIOUS FIX IS WRONG ON ITS OWN, which is why this file exists rather than a one-line filter. +`investor` declares `initial: prospect` and every record is stamped with it at creation, so on a +project where nobody ran the `commit` transition EVERY investor is a prospect and a filter empties +the cap table. The roadmap records that this is not hypothetical: it was implemented, and +`test_distwaterfall` — which builds three investors through the real API and expects a $2,000,000 +distribution — returned 0.0. `workflow_in_use` is the distinction that makes the filter safe. + +Run: PYTHONPATH=src ./.venv/bin/python test_cap_table_state.py""" +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test_cap_table_state.db") +os.environ.setdefault("STORAGE_DIR", "./_storage_test_cap_table_state") +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_cap_table_state.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.capital import cap_table # noqa: E402 +from aec_api.main import app # noqa: E402 + +FUNDED, PROSPECT, COMMITTED, EXITED = "funded", "prospect", "committed", "exited" + + +def _inv(n, state, commit, contributed=0.0, cls="LP"): + return {"id": n, "ref": n, "workflow_state": state, + "data": {"investor": n, "investor_class": cls, + "commitment": commit, "contributed": contributed}} + + +# --- the roadmap's own measured scenario, number for number ------------------------------------- +ct = cap_table([_inv("Anchor LP", FUNDED, 6_000_000, 6_000_000), + _inv("Second LP", FUNDED, 4_000_000, 4_000_000), + _inv("Maybe LP", PROSPECT, 10_000_000, 0)]) +by = {r["investor"]: r for r in ct["rows"]} +assert by["Anchor LP"]["ownership_pct"] == 60.0, by["Anchor LP"] # was 30.0 +assert by["Second LP"]["ownership_pct"] == 40.0, by["Second LP"] # was 20.0 +assert by["Maybe LP"]["ownership_pct"] == 0.0, by["Maybe LP"] # was 50.0 +assert ct["total_commitment"] == 10_000_000.0, ct["total_commitment"] + +# the prospect is NOT dropped — its money is reported, just not as ownership. Deleting the row would +# hide a real pipeline; counting it as ownership was the bug. +assert by["Maybe LP"]["commitment"] == 10_000_000.0 +assert ct["pipeline_commitment"] == 10_000_000.0, ct["pipeline_commitment"] + +# and it no longer sorts to the top as the largest apparent owner: rank is read as ownership. +assert [r["investor"] for r in ct["rows"]] == ["Anchor LP", "Second LP", "Maybe LP"], ct["rows"] + +# by_class must agree with the denominator, or the two halves of the same table contradict. +assert sum(ct["by_class"].values()) == ct["total_commitment"], ct["by_class"] + +# --- THE TRAP: every investor still sits at the stamped initial state --------------------------- +# This is the case that broke the naive filter. `prospect` here means "nobody used the workflow", +# not "not committed", so the table must behave exactly as it did before. +allp = cap_table([_inv("A", PROSPECT, 1_000_000), _inv("B", PROSPECT, 1_000_000), + _inv("C", PROSPECT, 1_000_000)]) +assert allp["workflow_in_use"] is False, allp["workflow_in_use"] +assert allp["total_commitment"] == 3_000_000.0, allp["total_commitment"] +assert {round(r["ownership_pct"], 2) for r in allp["rows"]} == {33.33}, allp["rows"] +assert allp["pipeline_commitment"] == 0.0, "nothing is pipeline when nothing is committed yet" + +# ONE investor moving off the initial state flips the reading for the whole project — that is the +# signal, and it is a project-level fact, not a per-row one. +mixed = cap_table([_inv("A", COMMITTED, 1_000_000), _inv("B", PROSPECT, 1_000_000), + _inv("C", PROSPECT, 1_000_000)]) +assert mixed["workflow_in_use"] is True +assert mixed["total_commitment"] == 1_000_000.0, mixed["total_commitment"] +assert mixed["pipeline_commitment"] == 2_000_000.0, mixed["pipeline_commitment"] + +# --- `exited` is evidence the workflow was used, but is not current ownership ------------------- +# An investor who has left does not hold a share; their presence still proves the workflow is live. +ex = cap_table([_inv("Gone", EXITED, 5_000_000, 5_000_000), _inv("Here", PROSPECT, 5_000_000)]) +assert ex["workflow_in_use"] is True, "an exited investor proves the workflow was used" +assert {r["investor"]: r["counts_toward_ownership"] for r in ex["rows"]} == { + "Gone": False, "Here": False}, ex["rows"] + +# --- the decision travels ON THE ROW, so seven consumers cannot disagree ------------------------ +for r in ct["rows"]: + assert "counts_toward_ownership" in r, r +assert [r["counts_toward_ownership"] for r in ct["rows"]] == [True, True, False] + +# --- and the money follows: a prospect must draw NO distribution --------------------------------- +# Mutation-checking exposed that `test_distwaterfall` passes even when `distwaterfall` ignores the +# flag — its fixture has no prospect, so the filter was unexercised. This is that missing case, built +# through the real API so the records carry the workflow's own stamped initial state. +with TestClient(app) as c: + pid = c.post("/projects", json={"name": "Waterfall states"}).json()["id"] + + def _mk(name, cls, commit, state=None): + rid = c.post(f"/projects/{pid}/modules/investor", + json={"data": {"investor": name, "investor_class": cls, + "commitment": commit}}).json()["id"] + if state: + c.post(f"/projects/{pid}/modules/investor/{rid}/transition", + json={"action": state}) + return rid + + _mk("Alpha LP", "LP", 900_000, "commit") # prospect -> committed + _mk("GP Co", "GP", 100_000, "commit") + _mk("Maybe LP", "LP", 9_000_000) # left at the stamped initial state + + w = c.post(f"/projects/{pid}/waterfall", json={"exit_amount": 2_000_000}).json() + per = {x["investor"]: x["distribution"] for x in w["per_investor"]} + assert "Maybe LP" not in per or per["Maybe LP"] == 0.0, per + # Alpha holds the whole LP class despite being outweighed 10:1 by an uncommitted interest. + assert per.get("Alpha LP", 0) > 0, per + assert round(sum(per.values()), 2) == round(w["total_distributable"], 2), (per, w) + +print("test_cap_table_state OK")