From 1bd4ea7b99954c9c7443ac70aae576640908620c Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 22 Oct 2026 12:25:59 -0500 Subject: [PATCH] feat(ledger): schedule facade takes custom curves, loans, preview, and an edited definition `create_schedule` carries the whole definition the operations take since robosystems v1.20.0: `method` (`straight_line` / `custom` / `effective_interest`), `periodic_amounts`, `loan_terms`, the entry's `legs` and `dimension_ids`; `monthly_amount` is optional, required only for straight-line. `preview_schedule` runs the generator without keeping it and returns the roll-forward, the warnings and, with `replaces_structure_id`, what the schedule being edited has posted. `rebuild_schedule` takes the edited definition, which is how a schedule is saved back while nothing has posted. Generated tier unchanged (2.31.0 already carried the operations). Claude-Session: https://claude.ai/code/session_01WaJSHXWM1Lnpdrf5P7wiYW --- robosystems_client/clients/ledger_client.py | 267 +++++++++++++++++--- tests/test_ledger_client.py | 184 +++++++++++++- 2 files changed, 413 insertions(+), 38 deletions(-) diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index 6640cf0..a200eb5 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -183,6 +183,9 @@ from ..api.robo_ledger_fiscal_close.rebuild_schedule import ( sync_detailed as op_rebuild_schedule, ) +from ..api.robo_ledger_fiscal_close.preview_schedule import ( + sync_detailed as op_preview_schedule, +) from ..api.robo_ledger_report_distribution.add_publish_list_members import ( sync_detailed as op_add_publish_list_members, ) @@ -644,6 +647,8 @@ from ..models.delete_schedule_arm import DeleteScheduleArm from ..models.delete_schedule_request import DeleteScheduleRequest from ..models.rebuild_schedule_request import RebuildScheduleRequest +from ..models.preview_schedule_request import PreviewScheduleRequest +from ..models.preview_schedule_response import PreviewScheduleResponse from ..models.update_legacy_arm import UpdateLegacyArm from ..models.update_forecast_arm import UpdateForecastArm from ..models.link_entity_taxonomy_request import LinkEntityTaxonomyRequest @@ -1907,6 +1912,83 @@ def delete_information_block( # ── Schedules ────────────────────────────────────────────────────── + @staticmethod + def _schedule_payload( + *, + name: str, + element_ids: list[str], + period_start: str, + period_end: str, + monthly_amount: int | None, + debit_element_id: str, + credit_element_id: str, + entry_type: str, + memo_template: str, + taxonomy_id: str | None, + method: str | None, + original_amount: int | None, + residual_value: int | None, + useful_life_months: int | None, + asset_element_id: str | None, + auto_reverse: bool, + booked_on: str | None, + entity_id: str | None, + periodic_amounts: list[int] | None, + loan_terms: dict[str, Any] | None, + legs: list[dict[str, Any]] | None, + dimension_ids: list[str] | None, + closed_through: str | None, + ) -> dict[str, Any]: + """The schedule definition ``create_schedule`` and ``preview_schedule`` + send, as the request's dict. Only what the caller set is sent, so the + server's defaults apply to the rest.""" + entry_template: dict[str, Any] = { + "debit_element_id": debit_element_id, + "credit_element_id": credit_element_id, + "entry_type": entry_type, + "memo_template": memo_template, + "auto_reverse": auto_reverse, + } + if legs: + entry_template["legs"] = legs + if dimension_ids: + entry_template["dimension_ids"] = dimension_ids + payload: dict[str, Any] = { + "name": name, + "element_ids": element_ids, + "period_start": period_start, + "period_end": period_end, + "entry_template": entry_template, + } + if monthly_amount is not None: + payload["monthly_amount"] = monthly_amount + if taxonomy_id: + payload["taxonomy_id"] = taxonomy_id + if entity_id: + payload["entity_id"] = entity_id + if closed_through is not None: + payload["closed_through"] = closed_through + schedule_metadata: dict[str, Any] = {} + if method: + schedule_metadata["method"] = method + if original_amount is not None: + schedule_metadata["original_amount"] = original_amount + if residual_value is not None: + schedule_metadata["residual_value"] = residual_value + if useful_life_months is not None: + schedule_metadata["useful_life_months"] = useful_life_months + if asset_element_id: + schedule_metadata["asset_element_id"] = asset_element_id + if booked_on is not None: + schedule_metadata["booked_on"] = booked_on + if periodic_amounts is not None: + schedule_metadata["periodic_amounts"] = periodic_amounts + if loan_terms is not None: + schedule_metadata["loan_terms"] = loan_terms + if schedule_metadata: + payload["schedule_metadata"] = schedule_metadata + return payload + def create_schedule( self, graph_id: str, @@ -1915,7 +1997,7 @@ def create_schedule( element_ids: list[str], period_start: str, period_end: str, - monthly_amount: int, + monthly_amount: int | None = None, debit_element_id: str, credit_element_id: str, entry_type: str = "closing", @@ -1929,50 +2011,59 @@ def create_schedule( auto_reverse: bool = False, booked_on: str | None = None, entity_id: str | None = None, + periodic_amounts: list[int] | None = None, + loan_terms: dict[str, Any] | None = None, + legs: list[dict[str, Any]] | None = None, + dimension_ids: list[str] | None = None, ) -> InformationBlockEnvelope: """Create a new schedule with pre-generated monthly facts. + Three methods. Straight-line (the default) spreads ``monthly_amount`` + over the months, the last absorbing rounding. ``method="custom"`` takes + ``periodic_amounts`` instead: one cents value per month, non-negative, + summing to ``original_amount`` less ``residual_value``. + ``method="effective_interest"`` is a loan: ``loan_terms`` (the fields of + :class:`~robosystems_client.models.loan_terms_request.LoanTermsRequest`: + ``principal``, ``annual_rate``, ``start_date``, ``note_element_id``, the + day count, payment frequency and amount, ``interest_only``, + ``maturity_date``) computes the amortization table, and ``legs`` (each + ``{"element_id", "side", "amount_source"}``) say which table column each + line of the period's entry takes; ``debit_element_id`` names the + interest debit leg and ``credit_element_id`` one credit leg. + ``booked_on`` (``YYYY-MM-DD``) is the day the schedule's cost went on the books, when that is before its first period. The schedule reconciliation carries the cost from that day. ``entity_id`` is the entity whose books the schedule belongs to; - ``None`` = the group parent. + ``None`` = the group parent. :meth:`preview_schedule` takes the same + arguments and shows the roll-forward without creating anything. """ - payload_dict: dict[str, Any] = { - "name": name, - "element_ids": element_ids, - "period_start": period_start, - "period_end": period_end, - "monthly_amount": monthly_amount, - "entry_template": { - "debit_element_id": debit_element_id, - "credit_element_id": credit_element_id, - "entry_type": entry_type, - "memo_template": memo_template, - "auto_reverse": auto_reverse, - }, - } - if taxonomy_id: - payload_dict["taxonomy_id"] = taxonomy_id - if entity_id: - payload_dict["entity_id"] = entity_id - schedule_metadata: dict[str, Any] = {} - if method: - schedule_metadata["method"] = method - if original_amount is not None: - schedule_metadata["original_amount"] = original_amount - if residual_value is not None: - schedule_metadata["residual_value"] = residual_value - if useful_life_months is not None: - schedule_metadata["useful_life_months"] = useful_life_months - if asset_element_id: - schedule_metadata["asset_element_id"] = asset_element_id - if booked_on is not None: - schedule_metadata["booked_on"] = booked_on - if schedule_metadata: - payload_dict["schedule_metadata"] = schedule_metadata - + payload_dict = self._schedule_payload( + name=name, + element_ids=element_ids, + period_start=period_start, + period_end=period_end, + monthly_amount=monthly_amount, + debit_element_id=debit_element_id, + credit_element_id=credit_element_id, + entry_type=entry_type, + memo_template=memo_template, + taxonomy_id=taxonomy_id, + method=method, + original_amount=original_amount, + residual_value=residual_value, + useful_life_months=useful_life_months, + asset_element_id=asset_element_id, + auto_reverse=auto_reverse, + booked_on=booked_on, + entity_id=entity_id, + periodic_amounts=periodic_amounts, + loan_terms=loan_terms, + legs=legs, + dimension_ids=dimension_ids, + closed_through=None, + ) payload = CreateScheduleRequest.from_dict(payload_dict) body = CreateScheduleArm(block_type="schedule", payload=payload) response = op_create_information_block( @@ -1981,6 +2072,83 @@ def create_schedule( envelope = self._call_op("Create schedule", response) return self._typed_result("Create schedule", envelope, InformationBlockEnvelope) + def preview_schedule( + self, + graph_id: str, + *, + name: str, + element_ids: list[str], + period_start: str, + period_end: str, + monthly_amount: int | None = None, + debit_element_id: str, + credit_element_id: str, + entry_type: str = "closing", + memo_template: str = "", + taxonomy_id: str | None = None, + method: str | None = None, + original_amount: int | None = None, + residual_value: int | None = None, + useful_life_months: int | None = None, + asset_element_id: str | None = None, + auto_reverse: bool = False, + booked_on: str | None = None, + entity_id: str | None = None, + periodic_amounts: list[int] | None = None, + loan_terms: dict[str, Any] | None = None, + legs: list[dict[str, Any]] | None = None, + dimension_ids: list[str] | None = None, + replaces_structure_id: str | None = None, + closed_through: str | None = None, + ) -> PreviewScheduleResponse: + """Generate a schedule from a definition without keeping it. + + The same generator and guards as :meth:`create_schedule`, run and + rolled back. The response carries ``envelope`` (what create would leave; + its ids provisional), ``roll_forward`` (the balance the schedule carries + period by period, with a loan's interest, payment, principal and current + portion) and ``warnings`` naming any active schedule already posting the + same accounts in the same months. A definition create would refuse is + refused here with the period that breaks. With ``replaces_structure_id`` + (the schedule being edited or recreated), ``replaces`` says what it has + posted and the path the change takes: ``rebuild`` (save through + :meth:`rebuild_schedule`) while nothing has posted, + ``terminate_and_succeed`` after. + """ + payload_dict = self._schedule_payload( + name=name, + element_ids=element_ids, + period_start=period_start, + period_end=period_end, + monthly_amount=monthly_amount, + debit_element_id=debit_element_id, + credit_element_id=credit_element_id, + entry_type=entry_type, + memo_template=memo_template, + taxonomy_id=taxonomy_id, + method=method, + original_amount=original_amount, + residual_value=residual_value, + useful_life_months=useful_life_months, + asset_element_id=asset_element_id, + auto_reverse=auto_reverse, + booked_on=booked_on, + entity_id=entity_id, + periodic_amounts=periodic_amounts, + loan_terms=loan_terms, + legs=legs, + dimension_ids=dimension_ids, + closed_through=closed_through, + ) + if replaces_structure_id: + payload_dict["replaces_structure_id"] = replaces_structure_id + body = PreviewScheduleRequest.from_dict(payload_dict) + response = op_preview_schedule( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Preview schedule", response) + return self._typed_result("Preview schedule", envelope, PreviewScheduleResponse) + def dispose_schedule( self, graph_id: str, @@ -2118,6 +2286,11 @@ def rebuild_schedule( structure_id: str, *, idempotency_key: str | None = None, + period_start: str | None = None, + period_end: str | None = None, + monthly_amount: int | None = None, + entry_template: dict[str, Any] | None = None, + schedule_metadata: dict[str, Any] | None = None, ) -> ScheduleCreatedResponse: """Rebuild a schedule in place — re-run the generator on an existing schedule. @@ -2132,11 +2305,31 @@ def rebuild_schedule( the schedule to today's close state. Use this to pick up a fixed generator without orphaning obligations. + It is also how an edit is saved while nothing has posted: + ``period_start``, ``period_end``, ``monthly_amount``, ``entry_template`` + (replacing the stored one whole) and ``schedule_metadata`` (only the + fields named) replace the stored definition before it regenerates, as + the schedule's envelope reads them back (``artifact.mechanics``). + Omitted fields keep their stored values. Refused once an entry has + posted; :meth:`preview_schedule` with ``replaces_structure_id`` says + which path an edit takes. + Supply ``idempotency_key`` to make the call safe to retry — replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409. """ - body = RebuildScheduleRequest(structure_id=structure_id) + body_dict: dict[str, Any] = {"structure_id": structure_id} + if period_start is not None: + body_dict["period_start"] = period_start + if period_end is not None: + body_dict["period_end"] = period_end + if monthly_amount is not None: + body_dict["monthly_amount"] = monthly_amount + if entry_template is not None: + body_dict["entry_template"] = entry_template + if schedule_metadata is not None: + body_dict["schedule_metadata"] = schedule_metadata + body = RebuildScheduleRequest.from_dict(body_dict) response = op_rebuild_schedule( graph_id=graph_id, body=body, diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index fc758d1..33ad247 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -2296,8 +2296,190 @@ def test_rebuild_schedule_passes_idempotency_key( client.rebuild_schedule(graph_id, "str_sched_1", idempotency_key="key-123") assert mock_op.call_args.kwargs["idempotency_key"] == "key-123" + # ── Period close additional ops ───────────────────────────────────────── -# ── Period close additional ops ───────────────────────────────────────── + @patch("robosystems_client.clients.ledger_client.op_create_information_block") + def test_create_schedule_with_a_custom_curve(self, mock_op, mock_config, graph_id): + mock_op.return_value = _mock_response( + _envelope("create-information-block", {"id": "str_curve"}) + ) + client = LedgerClient(mock_config) + client.create_schedule( + graph_id, + name="Software licence", + element_ids=["elem_exp", "elem_prepaid"], + period_start="2026-01-01", + period_end="2026-03-31", + debit_element_id="elem_exp", + credit_element_id="elem_prepaid", + method="custom", + original_amount=30000, + periodic_amounts=[10000, 10000, 10000], + ) + payload = mock_op.call_args.kwargs["body"].payload.to_dict() + assert "monthly_amount" not in payload + assert payload["schedule_metadata"]["method"] == "custom" + assert payload["schedule_metadata"]["original_amount"] == 30000 + assert payload["schedule_metadata"]["periodic_amounts"] == [10000, 10000, 10000] + + @patch("robosystems_client.clients.ledger_client.op_create_information_block") + def test_create_schedule_as_a_loan(self, mock_op, mock_config, graph_id): + mock_op.return_value = _mock_response( + _envelope("create-information-block", {"id": "str_loan"}) + ) + client = LedgerClient(mock_config) + client.create_schedule( + graph_id, + name="Line of credit", + element_ids=["elem_interest", "elem_accrued", "elem_note"], + period_start="2026-08-01", + period_end="2027-07-31", + debit_element_id="elem_interest", + credit_element_id="elem_accrued", + method="effective_interest", + loan_terms={ + "principal": 8_000_000, + "annual_rate": 0.099, + "start_date": "2026-08-05", + "day_count": "actual/360", + "interest_only": True, + "note_element_id": "elem_note", + }, + legs=[ + {"element_id": "elem_interest", "side": "debit", "amount_source": "interest"}, + {"element_id": "elem_accrued", "side": "credit", "amount_source": "interest"}, + ], + ) + payload = mock_op.call_args.kwargs["body"].payload.to_dict() + assert "monthly_amount" not in payload + terms = payload["schedule_metadata"]["loan_terms"] + assert terms["principal"] == 8_000_000 + assert terms["annual_rate"] == 0.099 + assert terms["start_date"] == "2026-08-05" + assert terms["day_count"] == "actual/360" + assert terms["interest_only"] is True + assert terms["note_element_id"] == "elem_note" + legs = payload["entry_template"]["legs"] + assert [leg["element_id"] for leg in legs] == ["elem_interest", "elem_accrued"] + assert [leg["side"] for leg in legs] == ["debit", "credit"] + assert {leg["amount_source"] for leg in legs} == {"interest"} + + @patch("robosystems_client.clients.ledger_client.op_preview_schedule") + def test_preview_schedule_sends_the_definition_and_what_it_replaces( + self, mock_op, mock_config, graph_id + ): + mock_op.return_value = _mock_response( + _envelope( + "preview-schedule", + { + "envelope": {"id": "struct_preview", "name": "Line of credit"}, + "roll_forward": [ + { + "period_start": "2026-08-01", + "period_end": "2026-08-31", + "opening": 80000.0, + "amount": 594.0, + "closing": 80000.0, + "scope": "historical", + "interest": 594.0, + } + ], + "warnings": [], + "replaces": { + "structure_id": "str_old", + "name": "Old line", + "posted_entry_count": 0, + "path": "rebuild", + }, + }, + ) + ) + client = LedgerClient(mock_config) + result = client.preview_schedule( + graph_id, + name="Line of credit", + element_ids=["elem_interest", "elem_accrued", "elem_note"], + period_start="2026-08-01", + period_end="2027-07-31", + debit_element_id="elem_interest", + credit_element_id="elem_accrued", + method="effective_interest", + loan_terms={ + "principal": 8_000_000, + "annual_rate": 0.099, + "start_date": "2026-08-05", + "day_count": "actual/360", + "interest_only": True, + "note_element_id": "elem_note", + }, + legs=[ + {"element_id": "elem_interest", "side": "debit", "amount_source": "interest"}, + {"element_id": "elem_accrued", "side": "credit", "amount_source": "interest"}, + ], + replaces_structure_id="str_old", + ) + assert result["roll_forward"][0]["amount"] == 594.0 + assert result["replaces"]["path"] == "rebuild" + assert mock_op.call_args.kwargs["graph_id"] == graph_id + body = mock_op.call_args.kwargs["body"].to_dict() + assert body["replaces_structure_id"] == "str_old" + assert body["schedule_metadata"]["loan_terms"]["annual_rate"] == 0.099 + assert body["entry_template"]["legs"][0]["amount_source"] == "interest" + + @patch("robosystems_client.clients.ledger_client.op_preview_schedule") + def test_preview_schedule_omits_what_was_not_asked( + self, mock_op, mock_config, graph_id + ): + mock_op.return_value = _mock_response( + _envelope("preview-schedule", {"envelope": {}, "roll_forward": []}) + ) + client = LedgerClient(mock_config) + client.preview_schedule( + graph_id, + name="Prepaid rent", + element_ids=["elem_rent", "elem_prepaid"], + period_start="2026-01-01", + period_end="2026-12-31", + monthly_amount=100000, + debit_element_id="elem_rent", + credit_element_id="elem_prepaid", + ) + body = mock_op.call_args.kwargs["body"].to_dict() + assert body["monthly_amount"] == 100000 + assert "replaces_structure_id" not in body + assert "schedule_metadata" not in body + assert "legs" not in body["entry_template"] + + @patch("robosystems_client.clients.ledger_client.op_rebuild_schedule") + def test_rebuild_schedule_saves_an_edited_definition( + self, mock_op, mock_config, graph_id + ): + mock_op.return_value = _mock_response( + _envelope( + "rebuild-schedule", {"structure_id": "str_sched_1", "total_periods": 18} + ) + ) + client = LedgerClient(mock_config) + client.rebuild_schedule( + graph_id, + "str_sched_1", + period_end="2027-06-30", + monthly_amount=12500, + entry_template={ + "debit_element_id": "elem_rent", + "credit_element_id": "elem_prepaid", + "memo_template": "Prepaid rent", + }, + schedule_metadata={"original_amount": 225000}, + ) + body = mock_op.call_args.kwargs["body"].to_dict() + assert body["structure_id"] == "str_sched_1" + assert body["period_end"] == "2027-06-30" + assert body["monthly_amount"] == 12500 + assert body["entry_template"]["memo_template"] == "Prepaid rent" + assert body["schedule_metadata"]["original_amount"] == 225000 + assert "period_start" not in body + assert "method" not in body["schedule_metadata"] @pytest.mark.unit