From 155546a81d9b7324f98a37759494d43975ab49b3 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 21 Oct 2026 22:49:14 -0500 Subject: [PATCH] feat(ledger): record_ownership_change The parent buying more of a member from its outside owners, staying on full consolidation: the share from a month and the price paid. Regenerated against robosystems #1921; `ownership_change` joins the adjustment kinds, and the schema snapshot takes allocationShare's description from #1919. The twin of typescript-client #293. Claude-Session: https://claude.ai/code/session_01VPxBBHkqhwkZ6Eq8EXSBR6 --- .../record_ownership_change.py | 302 ++++++++++++++++++ robosystems_client/clients/ledger_client.py | 35 ++ robosystems_client/graphql/schema.graphql | 10 +- robosystems_client/models/__init__.py | 12 + .../consolidation_proposal_adjustment_kind.py | 1 + ...tion_envelope_ownership_change_response.py | 158 +++++++++ ...velope_ownership_change_response_status.py | 10 + .../models/ownership_change_response.py | 155 +++++++++ .../models/record_ownership_change_request.py | 98 ++++++ tests/test_ledger_client.py | 30 ++ 10 files changed, 807 insertions(+), 4 deletions(-) create mode 100644 robosystems_client/api/robo_ledger_ledger_events/record_ownership_change.py create mode 100644 robosystems_client/models/operation_envelope_ownership_change_response.py create mode 100644 robosystems_client/models/operation_envelope_ownership_change_response_status.py create mode 100644 robosystems_client/models/ownership_change_response.py create mode 100644 robosystems_client/models/record_ownership_change_request.py diff --git a/robosystems_client/api/robo_ledger_ledger_events/record_ownership_change.py b/robosystems_client/api/robo_ledger_ledger_events/record_ownership_change.py new file mode 100644 index 0000000..923dfb6 --- /dev/null +++ b/robosystems_client/api/robo_ledger_ledger_events/record_ownership_change.py @@ -0,0 +1,302 @@ +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_ownership_change_response import ( + OperationEnvelopeOwnershipChangeResponse, +) +from ...models.record_ownership_change_request import RecordOwnershipChangeRequest +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: RecordOwnershipChangeRequest, + 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-ownership-change".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 | OperationEnvelopeOwnershipChangeResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeOwnershipChangeResponse.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 | OperationEnvelopeOwnershipChangeResponse]: + 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: RecordOwnershipChangeRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeOwnershipChangeResponse]: + """Record Ownership Change + + Record the parent buying more of a member consolidated in full from its outside owners: the share it + holds from a month (`ownership_pct`, staying 50 or more) and what it paid them in cents, with the + basis. It sets the member's share from that month and writes a draft in the consolidation entity, + dated the month's first day, that its close posts: ASC 810 makes it an equity transaction, so the + price above the book value of the capital bought goes against the investment, the share bought of + the member's results since it joined comes out of the outside owners' interest, and the difference + is the parent's equity, with no gain or loss. preview-consolidation-entries then reads the price as + part of the parent's investment and the new share from that month. Changes are recorded in order; + recording a month again replaces its draft, and a posted one is reversed first. A sale, or a change + across consolidation methods, is not supported yet. + + **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 (RecordOwnershipChangeRequest): The parent buys more of a member from its outside + owners, staying on the + same consolidation method: the share it holds from a month, and what it + paid. ASC 810 makes it an equity transaction, never a gain or loss. + + 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 | OperationEnvelopeOwnershipChangeResponse] + """ + + 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: RecordOwnershipChangeRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeOwnershipChangeResponse | None: + """Record Ownership Change + + Record the parent buying more of a member consolidated in full from its outside owners: the share it + holds from a month (`ownership_pct`, staying 50 or more) and what it paid them in cents, with the + basis. It sets the member's share from that month and writes a draft in the consolidation entity, + dated the month's first day, that its close posts: ASC 810 makes it an equity transaction, so the + price above the book value of the capital bought goes against the investment, the share bought of + the member's results since it joined comes out of the outside owners' interest, and the difference + is the parent's equity, with no gain or loss. preview-consolidation-entries then reads the price as + part of the parent's investment and the new share from that month. Changes are recorded in order; + recording a month again replaces its draft, and a posted one is reversed first. A sale, or a change + across consolidation methods, is not supported yet. + + **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 (RecordOwnershipChangeRequest): The parent buys more of a member from its outside + owners, staying on the + same consolidation method: the share it holds from a month, and what it + paid. ASC 810 makes it an equity transaction, never a gain or loss. + + 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 | OperationEnvelopeOwnershipChangeResponse + """ + + 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: RecordOwnershipChangeRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeOwnershipChangeResponse]: + """Record Ownership Change + + Record the parent buying more of a member consolidated in full from its outside owners: the share it + holds from a month (`ownership_pct`, staying 50 or more) and what it paid them in cents, with the + basis. It sets the member's share from that month and writes a draft in the consolidation entity, + dated the month's first day, that its close posts: ASC 810 makes it an equity transaction, so the + price above the book value of the capital bought goes against the investment, the share bought of + the member's results since it joined comes out of the outside owners' interest, and the difference + is the parent's equity, with no gain or loss. preview-consolidation-entries then reads the price as + part of the parent's investment and the new share from that month. Changes are recorded in order; + recording a month again replaces its draft, and a posted one is reversed first. A sale, or a change + across consolidation methods, is not supported yet. + + **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 (RecordOwnershipChangeRequest): The parent buys more of a member from its outside + owners, staying on the + same consolidation method: the share it holds from a month, and what it + paid. ASC 810 makes it an equity transaction, never a gain or loss. + + 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 | OperationEnvelopeOwnershipChangeResponse] + """ + + 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: RecordOwnershipChangeRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeOwnershipChangeResponse | None: + """Record Ownership Change + + Record the parent buying more of a member consolidated in full from its outside owners: the share it + holds from a month (`ownership_pct`, staying 50 or more) and what it paid them in cents, with the + basis. It sets the member's share from that month and writes a draft in the consolidation entity, + dated the month's first day, that its close posts: ASC 810 makes it an equity transaction, so the + price above the book value of the capital bought goes against the investment, the share bought of + the member's results since it joined comes out of the outside owners' interest, and the difference + is the parent's equity, with no gain or loss. preview-consolidation-entries then reads the price as + part of the parent's investment and the new share from that month. Changes are recorded in order; + recording a month again replaces its draft, and a posted one is reversed first. A sale, or a change + across consolidation methods, is not supported yet. + + **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 (RecordOwnershipChangeRequest): The parent buys more of a member from its outside + owners, staying on the + same consolidation method: the share it holds from a month, and what it + paid. ASC 810 makes it an equity transaction, never a gain or loss. + + 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 | OperationEnvelopeOwnershipChangeResponse + """ + + 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 f793003..7315f25 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -66,6 +66,9 @@ from ..api.robo_ledger_ledger_events.record_acquisition import ( sync_detailed as op_record_acquisition, ) +from ..api.robo_ledger_ledger_events.record_ownership_change import ( + sync_detailed as op_record_ownership_change, +) from ..api.robo_ledger_ledger_events.record_statement_balance import ( sync_detailed as op_record_statement_balance, ) @@ -622,6 +625,8 @@ from ..models.acquisition_allocation import AcquisitionAllocation from ..models.acquisition_response import AcquisitionResponse from ..models.record_acquisition_request import RecordAcquisitionRequest +from ..models.ownership_change_response import OwnershipChangeResponse +from ..models.record_ownership_change_request import RecordOwnershipChangeRequest from ..models.live_financial_statement_request import LiveFinancialStatementRequest from ..models.financial_statement_analysis_response import ( FinancialStatementAnalysisResponse, @@ -2489,6 +2494,36 @@ def record_acquisition( envelope = self._call_op("Record acquisition", response) return self._typed_result("Record acquisition", envelope, AcquisitionResponse) + def record_ownership_change( + self, + graph_id: str, + *, + entity_id: str, + period: str, + ownership_pct: float, + consideration: int, + basis: str, + ) -> OwnershipChangeResponse: + """Record the parent buying more of a member from its outside owners, + staying on full consolidation: the share it holds from ``period`` + (``YYYY-MM``) and the price paid in cents, with the ``basis``. Sets the + member's share from that month and writes a draft in the consolidation + entity that its close posts — an equity transaction under ASC 810.""" + body = RecordOwnershipChangeRequest( + entity_id=entity_id, + period=period, + ownership_pct=ownership_pct, + consideration=consideration, + basis=basis, + ) + response = op_record_ownership_change( + graph_id=graph_id, body=body, client=self._get_client() + ) + envelope = self._call_op("Record ownership change", response) + return self._typed_result( + "Record ownership change", envelope, OwnershipChangeResponse + ) + # ── Event blocks (generic preview + status transitions) ────────────── def create_event_block( diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index cb61c0e..e6e5a93 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -815,10 +815,12 @@ type Query { ): PublishListDetail """ - The outside owners' share of a member's result for one month: the - pro-rata figure its ownership gives, the override set with - `set-allocation-override` if any, and the share the consolidating - entries use. + The outside owners' share of a member's result for one month. + + The pro-rata figure its ownership gives, the override set with + `set-allocation-override` if any, and the share the consolidating entries + use. Null for an entity that is not a member consolidated in full that + month: its outside owners have no share the engine allocates. """ allocationShare( """The member.""" diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index a412d12..19d2f84 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -691,6 +691,12 @@ from .operation_envelope_live_financial_statement_response_status import ( OperationEnvelopeLiveFinancialStatementResponseStatus, ) +from .operation_envelope_ownership_change_response import ( + OperationEnvelopeOwnershipChangeResponse, +) +from .operation_envelope_ownership_change_response_status import ( + OperationEnvelopeOwnershipChangeResponseStatus, +) from .operation_envelope_portfolio_block_envelope import ( OperationEnvelopePortfolioBlockEnvelope, ) @@ -854,6 +860,7 @@ from .org_usage_response_daily_trend_item import OrgUsageResponseDailyTrendItem from .org_usage_response_graph_details_item import OrgUsageResponseGraphDetailsItem from .org_usage_summary import OrgUsageSummary +from .ownership_change_response import OwnershipChangeResponse from .passkey_delete_request import PasskeyDeleteRequest from .passkey_delete_request_assertion_type_0 import PasskeyDeleteRequestAssertionType0 from .passkey_info import PasskeyInfo @@ -944,6 +951,7 @@ from .reconciling_item_regenerated import ReconcilingItemRegenerated from .record_acquisition_request import RecordAcquisitionRequest from .record_opening_balance_request import RecordOpeningBalanceRequest +from .record_ownership_change_request import RecordOwnershipChangeRequest from .record_statement_balance_request import RecordStatementBalanceRequest from .recovery_codes_request import RecoveryCodesRequest from .recovery_codes_request_assertion_type_0 import RecoveryCodesRequestAssertionType0 @@ -1670,6 +1678,8 @@ "OperationEnvelopelistPublishListMemberResponseStatus", "OperationEnvelopeLiveFinancialStatementResponse", "OperationEnvelopeLiveFinancialStatementResponseStatus", + "OperationEnvelopeOwnershipChangeResponse", + "OperationEnvelopeOwnershipChangeResponseStatus", "OperationEnvelopePortfolioBlockEnvelope", "OperationEnvelopePortfolioBlockEnvelopeStatus", "OperationEnvelopePreviewConsolidationEntriesResponse", @@ -1749,6 +1759,7 @@ "OrgUsageResponseDailyTrendItem", "OrgUsageResponseGraphDetailsItem", "OrgUsageSummary", + "OwnershipChangeResponse", "PasskeyDeleteRequest", "PasskeyDeleteRequestAssertionType0", "PasskeyInfo", @@ -1823,6 +1834,7 @@ "ReconcilingItemRegenerated", "RecordAcquisitionRequest", "RecordOpeningBalanceRequest", + "RecordOwnershipChangeRequest", "RecordStatementBalanceRequest", "RecoveryCodesRequest", "RecoveryCodesRequestAssertionType0", diff --git a/robosystems_client/models/consolidation_proposal_adjustment_kind.py b/robosystems_client/models/consolidation_proposal_adjustment_kind.py index 2df5917..9c367dc 100644 --- a/robosystems_client/models/consolidation_proposal_adjustment_kind.py +++ b/robosystems_client/models/consolidation_proposal_adjustment_kind.py @@ -12,6 +12,7 @@ class ConsolidationProposalAdjustmentKind(str, Enum): MANUAL = "manual" NCI_ALLOCATION = "nci_allocation" NCI_DISTRIBUTION = "nci_distribution" + OWNERSHIP_CHANGE = "ownership_change" TRANSLATION = "translation" def __str__(self) -> str: diff --git a/robosystems_client/models/operation_envelope_ownership_change_response.py b/robosystems_client/models/operation_envelope_ownership_change_response.py new file mode 100644 index 0000000..1573cc6 --- /dev/null +++ b/robosystems_client/models/operation_envelope_ownership_change_response.py @@ -0,0 +1,158 @@ +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 ..models.operation_envelope_ownership_change_response_status import ( + OperationEnvelopeOwnershipChangeResponseStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.ownership_change_response import OwnershipChangeResponse + + +T = TypeVar("T", bound="OperationEnvelopeOwnershipChangeResponse") + + +@_attrs_define +class OperationEnvelopeOwnershipChangeResponse: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeOwnershipChangeResponseStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (None | OwnershipChangeResponse | Unset): Command-specific result payload + created_by (None | str | Unset): User ID that initiated the operation + idempotent_replay (bool | Unset): True when this envelope came from the idempotency cache — the underlying + command did not execute again. False on fresh executions. Default: False. + """ + + operation: str + operation_id: str + status: OperationEnvelopeOwnershipChangeResponseStatus + at: str + result: None | OwnershipChangeResponse | Unset = UNSET + created_by: None | str | Unset = UNSET + idempotent_replay: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.ownership_change_response import OwnershipChangeResponse + + operation = self.operation + + operation_id = self.operation_id + + status = self.status.value + + at = self.at + + result: dict[str, Any] | None | Unset + if isinstance(self.result, Unset): + result = UNSET + elif isinstance(self.result, OwnershipChangeResponse): + result = self.result.to_dict() + else: + result = self.result + + created_by: None | str | Unset + if isinstance(self.created_by, Unset): + created_by = UNSET + else: + created_by = self.created_by + + idempotent_replay = self.idempotent_replay + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operation": operation, + "operationId": operation_id, + "status": status, + "at": at, + } + ) + if result is not UNSET: + field_dict["result"] = result + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if idempotent_replay is not UNSET: + field_dict["idempotentReplay"] = idempotent_replay + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.ownership_change_response import OwnershipChangeResponse + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeOwnershipChangeResponseStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> None | OwnershipChangeResponse | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = OwnershipChangeResponse.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | OwnershipChangeResponse | Unset, data) + + result = _parse_result(d.pop("result", UNSET)) + + def _parse_created_by(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + idempotent_replay = d.pop("idempotentReplay", UNSET) + + operation_envelope_ownership_change_response = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_ownership_change_response.additional_properties = d + return operation_envelope_ownership_change_response + + @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/operation_envelope_ownership_change_response_status.py b/robosystems_client/models/operation_envelope_ownership_change_response_status.py new file mode 100644 index 0000000..440fe1a --- /dev/null +++ b/robosystems_client/models/operation_envelope_ownership_change_response_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeOwnershipChangeResponseStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/ownership_change_response.py b/robosystems_client/models/ownership_change_response.py new file mode 100644 index 0000000..e1ceb10 --- /dev/null +++ b/robosystems_client/models/ownership_change_response.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.journal_entry_response import JournalEntryResponse + + +T = TypeVar("T", bound="OwnershipChangeResponse") + + +@_attrs_define +class OwnershipChangeResponse: + """A change in the parent's share of a member as recorded: the draft in + the consolidation entity its close posts. + + Attributes: + entity_id (str): The member. + entity_name (str): The member's name. + period (str): The first month the new share holds. + from_pct (float): The parent's share the month before. + to_pct (float): The parent's share from `period`. + consideration (int): Cents paid to the outside owners. + book_value (int): Cents: the share bought of the member's capital, which the investment elimination moves from + the outside owners to the parent. + earnings_transferred (int): Cents: the share bought of the member's results since it joined, moved from the + outside owners' interest to the parent. + equity_adjustment (int): Cents taken from the parent's equity: the price above the carrying amount of what was + bought (negative when it paid less). + entry (JournalEntryResponse): The common response shape for journal entry write operations. + replaced (bool | Unset): True when it replaced an earlier draft for the month. Default: False. + """ + + entity_id: str + entity_name: str + period: str + from_pct: float + to_pct: float + consideration: int + book_value: int + earnings_transferred: int + equity_adjustment: int + entry: JournalEntryResponse + replaced: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entity_id = self.entity_id + + entity_name = self.entity_name + + period = self.period + + from_pct = self.from_pct + + to_pct = self.to_pct + + consideration = self.consideration + + book_value = self.book_value + + earnings_transferred = self.earnings_transferred + + equity_adjustment = self.equity_adjustment + + entry = self.entry.to_dict() + + replaced = self.replaced + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entity_id": entity_id, + "entity_name": entity_name, + "period": period, + "from_pct": from_pct, + "to_pct": to_pct, + "consideration": consideration, + "book_value": book_value, + "earnings_transferred": earnings_transferred, + "equity_adjustment": equity_adjustment, + "entry": entry, + } + ) + if replaced is not UNSET: + field_dict["replaced"] = replaced + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.journal_entry_response import JournalEntryResponse + + d = dict(src_dict) + entity_id = d.pop("entity_id") + + entity_name = d.pop("entity_name") + + period = d.pop("period") + + from_pct = d.pop("from_pct") + + to_pct = d.pop("to_pct") + + consideration = d.pop("consideration") + + book_value = d.pop("book_value") + + earnings_transferred = d.pop("earnings_transferred") + + equity_adjustment = d.pop("equity_adjustment") + + entry = JournalEntryResponse.from_dict(d.pop("entry")) + + replaced = d.pop("replaced", UNSET) + + ownership_change_response = cls( + entity_id=entity_id, + entity_name=entity_name, + period=period, + from_pct=from_pct, + to_pct=to_pct, + consideration=consideration, + book_value=book_value, + earnings_transferred=earnings_transferred, + equity_adjustment=equity_adjustment, + entry=entry, + replaced=replaced, + ) + + ownership_change_response.additional_properties = d + return ownership_change_response + + @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_ownership_change_request.py b/robosystems_client/models/record_ownership_change_request.py new file mode 100644 index 0000000..6ea1031 --- /dev/null +++ b/robosystems_client/models/record_ownership_change_request.py @@ -0,0 +1,98 @@ +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 + +T = TypeVar("T", bound="RecordOwnershipChangeRequest") + + +@_attrs_define +class RecordOwnershipChangeRequest: + """The parent buys more of a member from its outside owners, staying on the + same consolidation method: the share it holds from a month, and what it + paid. ASC 810 makes it an equity transaction, never a gain or loss. + + Attributes: + entity_id (str): The member, consolidated in full. + period (str): The month, as YYYY-MM. + ownership_pct (float): The parent's share from `period`, as a percentage: above its share the month before, and + 50 or more, so the member stays consolidated in full. + consideration (int): Cents the parent paid the outside owners for the shares. Its line names them, not the + member, which is why it is given here. + basis (str): The purchase agreement, or what sets the price. + """ + + entity_id: str + period: str + ownership_pct: float + consideration: int + basis: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entity_id = self.entity_id + + period = self.period + + ownership_pct = self.ownership_pct + + consideration = self.consideration + + basis = self.basis + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entity_id": entity_id, + "period": period, + "ownership_pct": ownership_pct, + "consideration": consideration, + "basis": basis, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + entity_id = d.pop("entity_id") + + period = d.pop("period") + + ownership_pct = d.pop("ownership_pct") + + consideration = d.pop("consideration") + + basis = d.pop("basis") + + record_ownership_change_request = cls( + entity_id=entity_id, + period=period, + ownership_pct=ownership_pct, + consideration=consideration, + basis=basis, + ) + + record_ownership_change_request.additional_properties = d + return record_ownership_change_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 a57f6a8..b15a856 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -1165,6 +1165,36 @@ def test_record_acquisition(self, mock_op, mock_config, graph_id): "confirm_bargain_purchase": False, } + @patch("robosystems_client.clients.ledger_client.op_record_ownership_change") + def test_record_ownership_change(self, mock_op, mock_config, graph_id): + envelope = _envelope( + "record-ownership-change", + { + "entity_id": "ent_cafe", + "from_pct": 70, + "to_pct": 80, + "equity_adjustment": 41750, + }, + ) + mock_op.return_value = _mock_response(envelope) + client = LedgerClient(mock_config) + result = client.record_ownership_change( + graph_id, + entity_id="ent_cafe", + period="2026-02", + ownership_pct=80, + consideration=150000, + basis="Share purchase agreement", + ) + assert result["equity_adjustment"] == 41750 + assert mock_op.call_args.kwargs["body"].to_dict() == { + "entity_id": "ent_cafe", + "period": "2026-02", + "ownership_pct": 80, + "consideration": 150000, + "basis": "Share purchase agreement", + } + # ── Taxonomy / entity linking ───────────────────────────────────────────