diff --git a/robosystems_client/api/robo_ledger_ledger_events/record_opening_balance.py b/robosystems_client/api/robo_ledger_ledger_events/record_opening_balance.py new file mode 100644 index 0000000..6a77dae --- /dev/null +++ b/robosystems_client/api/robo_ledger_ledger_events/record_opening_balance.py @@ -0,0 +1,294 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.operation_envelope_journal_entry_response import ( + OperationEnvelopeJournalEntryResponse, +) +from ...models.record_opening_balance_request import RecordOpeningBalanceRequest +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: RecordOpeningBalanceRequest, + idempotency_key: None | str | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + if not isinstance(idempotency_key, Unset): + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/extensions/roboledger/{graph_id}/operations/record-opening-balance".format( + graph_id=quote(str(graph_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | OperationEnvelopeJournalEntryResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeJournalEntryResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 422: + response_422 = ErrorResponse.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | OperationEnvelopeJournalEntryResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: RecordOpeningBalanceRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeJournalEntryResponse]: + """Record Opening Balance + + Record the balance sheet an entity's books start from, when its history does not come with them: one + balance per balance-sheet account (assets, liabilities, equity), in cents, debit or credit in the + account's normal direction. It posts as one `opening` entry dated the day before the entity's first + fiscal month, so every month's opening balances include it and no month's activity does: the cash + flow and the equity statement start from it rather than counting it. Name the equity account that + takes any difference between debits and credits (`equity_element_id`, usually Opening Balance + Equity); unbalanced balances without one are refused, and an income or expense account is refused, + since income before the books begin is in retained earnings. One per entity: recording it again + replaces it, until the entity's first close, after which a correction is an adjusting entry in an + open month. Set up the fiscal calendar first; moving its start (change-calendar-start) moves the + opening with it. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RecordOpeningBalanceRequest): The balance sheet a ledger starts from. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeJournalEntryResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + body: RecordOpeningBalanceRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeJournalEntryResponse | None: + """Record Opening Balance + + Record the balance sheet an entity's books start from, when its history does not come with them: one + balance per balance-sheet account (assets, liabilities, equity), in cents, debit or credit in the + account's normal direction. It posts as one `opening` entry dated the day before the entity's first + fiscal month, so every month's opening balances include it and no month's activity does: the cash + flow and the equity statement start from it rather than counting it. Name the equity account that + takes any difference between debits and credits (`equity_element_id`, usually Opening Balance + Equity); unbalanced balances without one are refused, and an income or expense account is refused, + since income before the books begin is in retained earnings. One per entity: recording it again + replaces it, until the entity's first close, after which a correction is an adjusting entry in an + open month. Set up the fiscal calendar first; moving its start (change-calendar-start) moves the + opening with it. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RecordOpeningBalanceRequest): The balance sheet a ledger starts from. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeJournalEntryResponse + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: RecordOpeningBalanceRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeJournalEntryResponse]: + """Record Opening Balance + + Record the balance sheet an entity's books start from, when its history does not come with them: one + balance per balance-sheet account (assets, liabilities, equity), in cents, debit or credit in the + account's normal direction. It posts as one `opening` entry dated the day before the entity's first + fiscal month, so every month's opening balances include it and no month's activity does: the cash + flow and the equity statement start from it rather than counting it. Name the equity account that + takes any difference between debits and credits (`equity_element_id`, usually Opening Balance + Equity); unbalanced balances without one are refused, and an income or expense account is refused, + since income before the books begin is in retained earnings. One per entity: recording it again + replaces it, until the entity's first close, after which a correction is an adjusting entry in an + open month. Set up the fiscal calendar first; moving its start (change-calendar-start) moves the + opening with it. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RecordOpeningBalanceRequest): The balance sheet a ledger starts from. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | OperationEnvelopeJournalEntryResponse] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + body: RecordOpeningBalanceRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeJournalEntryResponse | None: + """Record Opening Balance + + Record the balance sheet an entity's books start from, when its history does not come with them: one + balance per balance-sheet account (assets, liabilities, equity), in cents, debit or credit in the + account's normal direction. It posts as one `opening` entry dated the day before the entity's first + fiscal month, so every month's opening balances include it and no month's activity does: the cash + flow and the equity statement start from it rather than counting it. Name the equity account that + takes any difference between debits and credits (`equity_element_id`, usually Opening Balance + Equity); unbalanced balances without one are refused, and an income or expense account is refused, + since income before the books begin is in retained earnings. One per entity: recording it again + replaces it, until the entity's first close, after which a correction is an adjusting entry in an + open month. Set up the fiscal calendar first; moving its start (change-calendar-start) moves the + opening with it. + + **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours + return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. + + Args: + graph_id (str): + idempotency_key (None | str | Unset): + body (RecordOpeningBalanceRequest): The balance sheet a ledger starts from. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | OperationEnvelopeJournalEntryResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index f4c6f47..7b061c7 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -54,6 +54,9 @@ from ..api.robo_ledger_ledger_events.preview_reconciliations import ( sync_detailed as op_preview_reconciliations, ) +from ..api.robo_ledger_ledger_events.record_opening_balance import ( + sync_detailed as op_record_opening_balance, +) from ..api.robo_ledger_ledger_events.record_statement_balance import ( sync_detailed as op_record_statement_balance, ) @@ -545,6 +548,8 @@ from ..models.update_rollforward_arm import UpdateRollforwardArm from ..models.link_entity_taxonomy_request import LinkEntityTaxonomyRequest from ..models.update_journal_entry_request import UpdateJournalEntryRequest +from ..models.opening_balance_input import OpeningBalanceInput +from ..models.record_opening_balance_request import RecordOpeningBalanceRequest from ..models.update_schedule_arm import UpdateScheduleArm from ..models.update_schedule_request import UpdateScheduleRequest from ..models.close_period_operation import ClosePeriodOperation @@ -2136,6 +2141,39 @@ def reverse_journal_entry( envelope = self._call_op("Reverse journal entry", response) return self._typed_result("Reverse journal entry", envelope, EventBlockEnvelope) + def record_opening_balance( + self, + graph_id: str, + *, + balances: list[dict[str, Any]], + equity_element_id: str | None = None, + memo: str | None = None, + entity_id: str | None = None, + ) -> JournalEntryResponse: + """Record the balance sheet an entity's books start from. + + ``balances`` is one ``{"element_id", "debit_amount", "credit_amount"}`` + per balance-sheet account, in cents, in the account's normal direction. + It posts as one ``opening`` entry dated the day before the first fiscal + month, so every month's opening balances include it and no month's + activity does. ``equity_element_id`` (usually Opening Balance Equity) + takes any difference between debits and credits; unbalanced balances + without one are refused. Recording again replaces the entry until the + entity's first close. ``entity_id`` names the entity; omitted, the group + parent. + """ + body = RecordOpeningBalanceRequest( + balances=[OpeningBalanceInput.from_dict(b) for b in balances], + entity_id=entity_id if entity_id is not None else UNSET, + equity_element_id=(equity_element_id if equity_element_id is not None else UNSET), + memo=memo if memo is not None else UNSET, + ) + response = op_record_opening_balance( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Record opening balance", response) + return self._typed_result("Record opening balance", envelope, JournalEntryResponse) + # ── Event blocks (generic preview + status transitions) ────────────── def create_event_block( diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index 7b7360b..c9b2228 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -459,6 +459,7 @@ OfferingRepositoryPlanRateLimitsType0, ) from .oidc_provider_info import OIDCProviderInfo +from .opening_balance_input import OpeningBalanceInput from .operation_costs import OperationCosts from .operation_costs_ai_operations import OperationCostsAiOperations from .operation_costs_token_pricing import OperationCostsTokenPricing @@ -914,6 +915,7 @@ ReconcilingItemPlanDefaultDisposition, ) from .reconciling_item_regenerated import ReconcilingItemRegenerated +from .record_opening_balance_request import RecordOpeningBalanceRequest from .record_statement_balance_request import RecordStatementBalanceRequest from .recovery_codes_request import RecoveryCodesRequest from .recovery_codes_request_assertion_type_0 import RecoveryCodesRequestAssertionType0 @@ -1551,6 +1553,7 @@ "OfferingRepositoryPlan", "OfferingRepositoryPlanRateLimitsType0", "OIDCProviderInfo", + "OpeningBalanceInput", "OperationCosts", "OperationCostsAiOperations", "OperationCostsTokenPricing", @@ -1774,6 +1777,7 @@ "ReconcilingItemPlan", "ReconcilingItemPlanDefaultDisposition", "ReconcilingItemRegenerated", + "RecordOpeningBalanceRequest", "RecordStatementBalanceRequest", "RecoveryCodesRequest", "RecoveryCodesRequestAssertionType0", diff --git a/robosystems_client/models/live_financial_statement_response.py b/robosystems_client/models/live_financial_statement_response.py index e47aa2d..41b5a6d 100644 --- a/robosystems_client/models/live_financial_statement_response.py +++ b/robosystems_client/models/live_financial_statement_response.py @@ -36,6 +36,9 @@ class LiveFinancialStatementResponse: `consolidated` scopes. Default: False. combined_entity_ids (list[str] | Unset): The entities summed, parent first; on `consolidated` the consolidation entity comes last. + omitted_periods (list[PeriodSpec] | Unset): Columns left out because they reach back before the books begin: on + books that start from an opening balance, a flow statement column that starts before the first day it covers, or + a balance sheet dated before it. They would show a partial figure, not the months before the books. validation (None | Unset | ValidationCheckResponse): Guard-rail outcome for the rendered columns — accounting equation, net-income equation, totals footing, operating-plug size. Null only when no structure rendered. unmapped_count (int | Unset): Default: 0. @@ -51,6 +54,7 @@ class LiveFinancialStatementResponse: scope: str | Unset = "entity" consolidated: bool | Unset = False combined_entity_ids: list[str] | Unset = UNSET + omitted_periods: list[PeriodSpec] | Unset = UNSET validation: None | Unset | ValidationCheckResponse = UNSET unmapped_count: int | Unset = 0 truncated: bool | Unset = False @@ -89,6 +93,13 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.combined_entity_ids, Unset): combined_entity_ids = self.combined_entity_ids + omitted_periods: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.omitted_periods, Unset): + omitted_periods = [] + for omitted_periods_item_data in self.omitted_periods: + omitted_periods_item = omitted_periods_item_data.to_dict() + omitted_periods.append(omitted_periods_item) + validation: dict[str, Any] | None | Unset if isinstance(self.validation, Unset): validation = UNSET @@ -120,6 +131,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["consolidated"] = consolidated if combined_entity_ids is not UNSET: field_dict["combined_entity_ids"] = combined_entity_ids + if omitted_periods is not UNSET: + field_dict["omitted_periods"] = omitted_periods if validation is not UNSET: field_dict["validation"] = validation if unmapped_count is not UNSET: @@ -171,6 +184,15 @@ def _parse_entity_id(data: object) -> None | str | Unset: combined_entity_ids = cast(list[str], d.pop("combined_entity_ids", UNSET)) + _omitted_periods = d.pop("omitted_periods", UNSET) + omitted_periods: list[PeriodSpec] | Unset = UNSET + if _omitted_periods is not UNSET: + omitted_periods = [] + for omitted_periods_item_data in _omitted_periods: + omitted_periods_item = PeriodSpec.from_dict(omitted_periods_item_data) + + omitted_periods.append(omitted_periods_item) + def _parse_validation(data: object) -> None | Unset | ValidationCheckResponse: if data is None: return data @@ -202,6 +224,7 @@ def _parse_validation(data: object) -> None | Unset | ValidationCheckResponse: scope=scope, consolidated=consolidated, combined_entity_ids=combined_entity_ids, + omitted_periods=omitted_periods, validation=validation, unmapped_count=unmapped_count, truncated=truncated, diff --git a/robosystems_client/models/opening_balance_input.py b/robosystems_client/models/opening_balance_input.py new file mode 100644 index 0000000..0102d0f --- /dev/null +++ b/robosystems_client/models/opening_balance_input.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="OpeningBalanceInput") + + +@_attrs_define +class OpeningBalanceInput: + """One account's balance on the day the books begin, in its normal + direction: an asset as a debit, a liability or equity account as a credit + (a contra account the other way). + + Attributes: + element_id (str): The balance-sheet account, by id. + debit_amount (int | Unset): Debit balance in cents. Default: 0. + credit_amount (int | Unset): Credit balance in cents. Default: 0. + """ + + element_id: str + debit_amount: int | Unset = 0 + credit_amount: int | Unset = 0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + element_id = self.element_id + + debit_amount = self.debit_amount + + credit_amount = self.credit_amount + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "element_id": element_id, + } + ) + if debit_amount is not UNSET: + field_dict["debit_amount"] = debit_amount + if credit_amount is not UNSET: + field_dict["credit_amount"] = credit_amount + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + element_id = d.pop("element_id") + + debit_amount = d.pop("debit_amount", UNSET) + + credit_amount = d.pop("credit_amount", UNSET) + + opening_balance_input = cls( + element_id=element_id, + debit_amount=debit_amount, + credit_amount=credit_amount, + ) + + opening_balance_input.additional_properties = d + return opening_balance_input + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/robosystems_client/models/record_opening_balance_request.py b/robosystems_client/models/record_opening_balance_request.py new file mode 100644 index 0000000..811aba9 --- /dev/null +++ b/robosystems_client/models/record_opening_balance_request.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.opening_balance_input import OpeningBalanceInput + + +T = TypeVar("T", bound="RecordOpeningBalanceRequest") + + +@_attrs_define +class RecordOpeningBalanceRequest: + """The balance sheet a ledger starts from. + + Attributes: + balances (list[OpeningBalanceInput]): One balance per balance-sheet account (assets, liabilities, equity). An + income or expense account is refused: income before the books begin is already in retained earnings. + entity_id (None | str | Unset): The entity whose books start here; omit for the group parent. + equity_element_id (None | str | Unset): The equity account that takes any difference between debits and credits, + usually Opening Balance Equity. Required when the balances do not balance; a non-zero balance left there is a + review item. + memo (None | str | Unset): The entry's memo; defaults to 'Opening balances'. + """ + + balances: list[OpeningBalanceInput] + entity_id: None | str | Unset = UNSET + equity_element_id: None | str | Unset = UNSET + memo: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + balances = [] + for balances_item_data in self.balances: + balances_item = balances_item_data.to_dict() + balances.append(balances_item) + + entity_id: None | str | Unset + if isinstance(self.entity_id, Unset): + entity_id = UNSET + else: + entity_id = self.entity_id + + equity_element_id: None | str | Unset + if isinstance(self.equity_element_id, Unset): + equity_element_id = UNSET + else: + equity_element_id = self.equity_element_id + + memo: None | str | Unset + if isinstance(self.memo, Unset): + memo = UNSET + else: + memo = self.memo + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "balances": balances, + } + ) + if entity_id is not UNSET: + field_dict["entity_id"] = entity_id + if equity_element_id is not UNSET: + field_dict["equity_element_id"] = equity_element_id + if memo is not UNSET: + field_dict["memo"] = memo + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.opening_balance_input import OpeningBalanceInput + + d = dict(src_dict) + balances = [] + _balances = d.pop("balances") + for balances_item_data in _balances: + balances_item = OpeningBalanceInput.from_dict(balances_item_data) + + balances.append(balances_item) + + def _parse_entity_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + entity_id = _parse_entity_id(d.pop("entity_id", UNSET)) + + def _parse_equity_element_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + equity_element_id = _parse_equity_element_id(d.pop("equity_element_id", UNSET)) + + def _parse_memo(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + memo = _parse_memo(d.pop("memo", UNSET)) + + record_opening_balance_request = cls( + balances=balances, + entity_id=entity_id, + equity_element_id=equity_element_id, + memo=memo, + ) + + record_opening_balance_request.additional_properties = d + return record_opening_balance_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index d49964f..f295e54 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -921,6 +921,33 @@ def test_reverse_journal_entry(self, mock_op, mock_config, graph_id): assert metadata["memo"] == "Reversal of Q1 entry" assert metadata["posting_date"] == "2026-04-01" + @patch("robosystems_client.clients.ledger_client.op_record_opening_balance") + def test_record_opening_balance(self, mock_op, mock_config, graph_id): + envelope = _envelope( + "record-opening-balance", + {"id": "je_open", "type": "opening", "posting_date": "2025-12-31"}, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.record_opening_balance( + graph_id, + balances=[ + {"element_id": "elem_cash", "debit_amount": 1500000}, + {"element_id": "elem_loan", "credit_amount": 1000000}, + ], + equity_element_id="elem_obe", + entity_id="ent_1", + ) + assert result["type"] == "opening" + assert mock_op.call_args.kwargs["body"].to_dict() == { + "balances": [ + {"element_id": "elem_cash", "debit_amount": 1500000}, + {"element_id": "elem_loan", "credit_amount": 1000000}, + ], + "entity_id": "ent_1", + "equity_element_id": "elem_obe", + } + # ── Taxonomy / entity linking ───────────────────────────────────────────