diff --git a/robosystems_client/api/robo_ledger_setup/link_bank_account.py b/robosystems_client/api/robo_ledger_setup/link_bank_account.py new file mode 100644 index 00000000..f049fe94 --- /dev/null +++ b/robosystems_client/api/robo_ledger_setup/link_bank_account.py @@ -0,0 +1,310 @@ +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.link_bank_account_request import LinkBankAccountRequest +from ...models.operation_envelope_link_bank_account_response import ( + OperationEnvelopeLinkBankAccountResponse, +) +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: LinkBankAccountRequest, + 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/link-bank-account".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 | OperationEnvelopeLinkBankAccountResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeLinkBankAccountResponse.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 | OperationEnvelopeLinkBankAccountResponse]: + 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: LinkBankAccountRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeLinkBankAccountResponse]: + """Link Bank Account + + Point a bank feed's account at a chart account. Name `element_id` for an existing active account, or + `entity_id` alone to create one in that entity's chart. The chart the account is in decides whose + books the feed's lines go into, so this is also how a feed account is bound to a subsidiary. Lines + still in the inbox move with it (across an entity change their suggestion is resolved again on the + new chart, and a classification that named the old entity's account is dropped); posted entries stay + where they were posted. An account another connection already feeds is refused. An account the feed + created and then left stays on its chart as an ordinary account. A sync already in flight when the + link moves can still land a line or two on the old account; running this again moves them. Read the + group's accounts with the `bankAccounts` GraphQL field. + + **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 (LinkBankAccountRequest): Point a bank feed's account at a chart account. + + Name `element_id` for an existing active account, or `entity_id` alone to + create one in that entity's chart. The chart the account is in decides + whose books the feed's lines go into, so this is also how a feed account + is bound to a subsidiary. Lines still in the inbox move with it; posted + entries stay where they were posted. + + 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 | OperationEnvelopeLinkBankAccountResponse] + """ + + 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: LinkBankAccountRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeLinkBankAccountResponse | None: + """Link Bank Account + + Point a bank feed's account at a chart account. Name `element_id` for an existing active account, or + `entity_id` alone to create one in that entity's chart. The chart the account is in decides whose + books the feed's lines go into, so this is also how a feed account is bound to a subsidiary. Lines + still in the inbox move with it (across an entity change their suggestion is resolved again on the + new chart, and a classification that named the old entity's account is dropped); posted entries stay + where they were posted. An account another connection already feeds is refused. An account the feed + created and then left stays on its chart as an ordinary account. A sync already in flight when the + link moves can still land a line or two on the old account; running this again moves them. Read the + group's accounts with the `bankAccounts` GraphQL field. + + **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 (LinkBankAccountRequest): Point a bank feed's account at a chart account. + + Name `element_id` for an existing active account, or `entity_id` alone to + create one in that entity's chart. The chart the account is in decides + whose books the feed's lines go into, so this is also how a feed account + is bound to a subsidiary. Lines still in the inbox move with it; posted + entries stay where they were posted. + + 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 | OperationEnvelopeLinkBankAccountResponse + """ + + 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: LinkBankAccountRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeLinkBankAccountResponse]: + """Link Bank Account + + Point a bank feed's account at a chart account. Name `element_id` for an existing active account, or + `entity_id` alone to create one in that entity's chart. The chart the account is in decides whose + books the feed's lines go into, so this is also how a feed account is bound to a subsidiary. Lines + still in the inbox move with it (across an entity change their suggestion is resolved again on the + new chart, and a classification that named the old entity's account is dropped); posted entries stay + where they were posted. An account another connection already feeds is refused. An account the feed + created and then left stays on its chart as an ordinary account. A sync already in flight when the + link moves can still land a line or two on the old account; running this again moves them. Read the + group's accounts with the `bankAccounts` GraphQL field. + + **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 (LinkBankAccountRequest): Point a bank feed's account at a chart account. + + Name `element_id` for an existing active account, or `entity_id` alone to + create one in that entity's chart. The chart the account is in decides + whose books the feed's lines go into, so this is also how a feed account + is bound to a subsidiary. Lines still in the inbox move with it; posted + entries stay where they were posted. + + 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 | OperationEnvelopeLinkBankAccountResponse] + """ + + 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: LinkBankAccountRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeLinkBankAccountResponse | None: + """Link Bank Account + + Point a bank feed's account at a chart account. Name `element_id` for an existing active account, or + `entity_id` alone to create one in that entity's chart. The chart the account is in decides whose + books the feed's lines go into, so this is also how a feed account is bound to a subsidiary. Lines + still in the inbox move with it (across an entity change their suggestion is resolved again on the + new chart, and a classification that named the old entity's account is dropped); posted entries stay + where they were posted. An account another connection already feeds is refused. An account the feed + created and then left stays on its chart as an ordinary account. A sync already in flight when the + link moves can still land a line or two on the old account; running this again moves them. Read the + group's accounts with the `bankAccounts` GraphQL field. + + **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 (LinkBankAccountRequest): Point a bank feed's account at a chart account. + + Name `element_id` for an existing active account, or `entity_id` alone to + create one in that entity's chart. The chart the account is in decides + whose books the feed's lines go into, so this is also how a feed account + is bound to a subsidiary. Lines still in the inbox move with it; posted + entries stay where they were posted. + + 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 | OperationEnvelopeLinkBankAccountResponse + """ + + 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 2cf2c4d0..117cc178 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -135,6 +135,9 @@ from ..api.robo_ledger_setup.create_entity import ( sync_detailed as op_create_entity, ) +from ..api.robo_ledger_setup.link_bank_account import ( + sync_detailed as op_link_bank_account, +) from ..api.robo_ledger_setup.update_entity import ( sync_detailed as op_update_entity, ) @@ -224,6 +227,12 @@ from ..graphql.generated.get_ledger_account_tree import ( GetLedgerAccountTreeAccountTree as LedgerAccountTree, ) +from ..graphql.generated.list_ledger_bank_accounts import ( + ListLedgerBankAccounts, +) +from ..graphql.generated.list_ledger_bank_accounts import ( + ListLedgerBankAccountsBankAccounts as LedgerBankAccountList, +) from ..graphql.generated.get_ledger_agent import ( GetLedgerAgent, ) @@ -428,6 +437,7 @@ GET_LEDGER_ACCOUNT_ROLLUPS_GQL, GET_LEDGER_ACCOUNT_TREE_GQL, GET_LEDGER_AGENT_GQL, + LIST_LEDGER_BANK_ACCOUNTS_GQL, GET_LEDGER_CLOSING_BOOK_STRUCTURES_GQL, GET_LEDGER_ENTITY_GQL, GET_LEDGER_EVENT_BLOCK_GQL, @@ -469,6 +479,8 @@ from ..models.auto_map_elements_operation import AutoMapElementsOperation from ..models.create_agent_request import CreateAgentRequest from ..models.create_entity_request import CreateEntityRequest +from ..models.link_bank_account_request import LinkBankAccountRequest +from ..models.link_bank_account_response import LinkBankAccountResponse from ..models.create_event_block_request import CreateEventBlockRequest from ..models.create_event_block_request_event_category import ( CreateEventBlockRequestEventCategory, @@ -945,6 +957,41 @@ def get_account_tree( data = self._query(graph_id, GET_LEDGER_ACCOUNT_TREE_GQL, self._scope(entity_id)) return GetLedgerAccountTree.model_validate(data).account_tree + # ── Bank accounts ─────────────────────────────────────────────────── + + def list_bank_accounts( + self, graph_id: str, entity_id: str | None = None + ) -> LedgerBankAccountList | None: + """The group's bank and card accounts. + + Every chart account a feed books to, or that a source system types as a + bank or card account, with the entity whose chart it is in (the books + its lines go into), what writes to it (``source``: a feed provider, + ``quickbooks``, or None for an account kept by hand) and the connection's + status and last sync. ``entity_id`` narrows to one entity; None is the + whole group. + """ + data = self._query(graph_id, LIST_LEDGER_BANK_ACCOUNTS_GQL, {"entityId": entity_id}) + return ListLedgerBankAccounts.model_validate(data).bank_accounts + + def link_bank_account( + self, graph_id: str, body: dict[str, Any] + ) -> LinkBankAccountResponse: + """Point a bank feed's account at a chart account. + + ``element_id`` links an existing account (its chart's entity takes the + feed); ``entity_id`` alone creates one in that entity's chart. This is + how a feed account is bound to a subsidiary: lines still in the inbox + move with it, posted entries stay where they were posted. An account + another connection already feeds is refused. + """ + request = LinkBankAccountRequest.from_dict(body) + response = op_link_bank_account( + graph_id=graph_id, body=request, client=self._get_client() + ) + envelope = self._call_op("Link bank account", response) + return self._typed_result("Link bank account", envelope, LinkBankAccountResponse) + def get_account_rollups( self, graph_id: str, diff --git a/robosystems_client/graphql/generated/__init__.py b/robosystems_client/graphql/generated/__init__.py index 7679df50..aeaa6a09 100644 --- a/robosystems_client/graphql/generated/__init__.py +++ b/robosystems_client/graphql/generated/__init__.py @@ -234,6 +234,11 @@ ListLedgerAccountsAccountsPagination, ) from .list_ledger_agents import ListLedgerAgents, ListLedgerAgentsAgents +from .list_ledger_bank_accounts import ( + ListLedgerBankAccounts, + ListLedgerBankAccountsBankAccounts, + ListLedgerBankAccountsBankAccountsAccounts, +) from .list_ledger_blocked_source_graphs import ( ListLedgerBlockedSourceGraphs, ListLedgerBlockedSourceGraphsBlockedSourceGraphs, @@ -363,6 +368,7 @@ LIST_INVESTOR_SECURITIES_GQL, LIST_LEDGER_ACCOUNTS_GQL, LIST_LEDGER_AGENTS_GQL, + LIST_LEDGER_BANK_ACCOUNTS_GQL, LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, LIST_LEDGER_ELEMENTS_GQL, LIST_LEDGER_ENTITIES_GQL, @@ -574,6 +580,7 @@ "LIST_INVESTOR_SECURITIES_GQL", "LIST_LEDGER_ACCOUNTS_GQL", "LIST_LEDGER_AGENTS_GQL", + "LIST_LEDGER_BANK_ACCOUNTS_GQL", "LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL", "LIST_LEDGER_ELEMENTS_GQL", "LIST_LEDGER_ENTITIES_GQL", @@ -630,6 +637,9 @@ "ListLedgerAccountsAccountsPagination", "ListLedgerAgents", "ListLedgerAgentsAgents", + "ListLedgerBankAccounts", + "ListLedgerBankAccountsBankAccounts", + "ListLedgerBankAccountsBankAccountsAccounts", "ListLedgerBlockedSourceGraphs", "ListLedgerBlockedSourceGraphsBlockedSourceGraphs", "ListLedgerBlockedSourceGraphsBlockedSourceGraphsBlockedSourceGraphs", diff --git a/robosystems_client/graphql/generated/client.py b/robosystems_client/graphql/generated/client.py index 31f48e46..0c2ae170 100644 --- a/robosystems_client/graphql/generated/client.py +++ b/robosystems_client/graphql/generated/client.py @@ -41,6 +41,7 @@ from .list_investor_securities import ListInvestorSecurities from .list_ledger_accounts import ListLedgerAccounts from .list_ledger_agents import ListLedgerAgents +from .list_ledger_bank_accounts import ListLedgerBankAccounts from .list_ledger_blocked_source_graphs import ListLedgerBlockedSourceGraphs from .list_ledger_elements import ListLedgerElements from .list_ledger_entities import ListLedgerEntities @@ -98,6 +99,7 @@ LIST_INVESTOR_SECURITIES_GQL, LIST_LEDGER_ACCOUNTS_GQL, LIST_LEDGER_AGENTS_GQL, + LIST_LEDGER_BANK_ACCOUNTS_GQL, LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL, LIST_LEDGER_ELEMENTS_GQL, LIST_LEDGER_ENTITIES_GQL, @@ -657,6 +659,19 @@ def list_ledger_agents( data = self.get_data(response) return ListLedgerAgents.model_validate(data) + def list_ledger_bank_accounts( + self, entity_id: Union[Optional[str], UnsetType] = UNSET, **kwargs: Any + ) -> ListLedgerBankAccounts: + variables: dict[str, object] = {"entityId": entity_id} + response = self.execute( + query=LIST_LEDGER_BANK_ACCOUNTS_GQL, + operation_name="ListLedgerBankAccounts", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return ListLedgerBankAccounts.model_validate(data) + def list_ledger_blocked_source_graphs( self, limit: int, offset: int, **kwargs: Any ) -> ListLedgerBlockedSourceGraphs: diff --git a/robosystems_client/graphql/generated/list_ledger_bank_accounts.py b/robosystems_client/graphql/generated/list_ledger_bank_accounts.py new file mode 100644 index 00000000..1d11f1f6 --- /dev/null +++ b/robosystems_client/graphql/generated/list_ledger_bank_accounts.py @@ -0,0 +1,40 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class ListLedgerBankAccounts(BaseModel): + bank_accounts: Optional["ListLedgerBankAccountsBankAccounts"] = Field( + alias="bankAccounts" + ) + + +class ListLedgerBankAccountsBankAccounts(BaseModel): + total: int + accounts: list["ListLedgerBankAccountsBankAccountsAccounts"] + + +class ListLedgerBankAccountsBankAccountsAccounts(BaseModel): + id: str + code: Optional[str] + name: str + kind: str + balance_type: str = Field(alias="balanceType") + is_active: bool = Field(alias="isActive") + entity_id: Optional[str] = Field(alias="entityId") + entity_name: Optional[str] = Field(alias="entityName") + source: Optional[str] + connection_id: Optional[str] = Field(alias="connectionId") + institution: Optional[str] + feed_account_id: Optional[str] = Field(alias="feedAccountId") + feed_account_name: Optional[str] = Field(alias="feedAccountName") + feed_account_kind: Optional[str] = Field(alias="feedAccountKind") + connection_status: Optional[str] = Field(alias="connectionStatus") + last_sync_at: Optional[str] = Field(alias="lastSyncAt") + last_sync_status: Optional[str] = Field(alias="lastSyncStatus") + + +ListLedgerBankAccounts.model_rebuild() +ListLedgerBankAccountsBankAccounts.model_rebuild() diff --git a/robosystems_client/graphql/generated/operations.py b/robosystems_client/graphql/generated/operations.py index 5b6bf5f5..3ebd5d33 100644 --- a/robosystems_client/graphql/generated/operations.py +++ b/robosystems_client/graphql/generated/operations.py @@ -37,6 +37,7 @@ "LIST_INVESTOR_SECURITIES_GQL", "LIST_LEDGER_ACCOUNTS_GQL", "LIST_LEDGER_AGENTS_GQL", + "LIST_LEDGER_BANK_ACCOUNTS_GQL", "LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL", "LIST_LEDGER_ELEMENTS_GQL", "LIST_LEDGER_ENTITIES_GQL", @@ -1402,6 +1403,33 @@ } """ +LIST_LEDGER_BANK_ACCOUNTS_GQL = """ +query ListLedgerBankAccounts($entityId: String) { + bankAccounts(entityId: $entityId) { + total + accounts { + id + code + name + kind + balanceType + isActive + entityId + entityName + source + connectionId + institution + feedAccountId + feedAccountName + feedAccountKind + connectionStatus + lastSyncAt + lastSyncStatus + } + } +} +""" + LIST_LEDGER_BLOCKED_SOURCE_GRAPHS_GQL = """ query ListLedgerBlockedSourceGraphs($limit: Int! = 100, $offset: Int! = 0) { blockedSourceGraphs(limit: $limit, offset: $offset) { diff --git a/robosystems_client/graphql/operations/ledger/ListLedgerBankAccounts.graphql b/robosystems_client/graphql/operations/ledger/ListLedgerBankAccounts.graphql new file mode 100644 index 00000000..3bdf7f29 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/ListLedgerBankAccounts.graphql @@ -0,0 +1,12 @@ +query ListLedgerBankAccounts($entityId: String) { + bankAccounts(entityId: $entityId) { + total + accounts { + id code name kind balanceType isActive + entityId entityName + source connectionId institution + feedAccountId feedAccountName feedAccountKind + connectionStatus lastSyncAt lastSyncStatus + } + } +} diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index b298df3e..47563927 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -264,6 +264,18 @@ type Query { entityId: String = null ): AccountTree + """ + The group's bank and card accounts, with what writes to each. + + Every chart account a feed books to, or that a source system types as a + bank or card account, with the entity whose chart it is in and the + health of the connection that writes to it. + """ + bankAccounts( + """One entity's accounts. Omit for every entity in the group.""" + entityId: String = null + ): BankAccountList + """CoA accounts grouped by reporting element with balances.""" accountRollups( """ @@ -1649,6 +1661,74 @@ type AccountTreeNode { children: [AccountTreeNode!]! } +"""Every bank and card account across the group, or one entity's.""" +type BankAccountList { + """The accounts, by code then name.""" + accounts: [BankAccount!]! + + """How many accounts are listed.""" + total: Int! +} + +""" +One bank or card account on an entity's chart, with the feed that +books to it, if any. +""" +type BankAccount { + """The chart account (element id).""" + id: String! + + """The account's code on its chart.""" + code: String + + """The account's name on its chart.""" + name: String! + + """`bank` or `credit`, from the balance side.""" + kind: String! + + """`debit` or `credit`.""" + balanceType: String! + + """False once the account is retired.""" + isActive: Boolean! + + """The entity whose chart the account is in; its lines book here.""" + entityId: String + + """That entity's name.""" + entityName: String + + """ + What writes to the account: a bank feed provider (`plaid`, `mercury`), `quickbooks` for a synced account, or null for one kept by hand. + """ + source: String + + """The connection that writes to the account, if one does.""" + connectionId: String + + """The bank the feed account is at, as the provider names it.""" + institution: String + + """The provider's id for the account, for `link-bank-account`.""" + feedAccountId: String + + """The account's name at the bank, with its mask.""" + feedAccountName: String + + """The provider's account kind (`checking`, `credit card`, ...).""" + feedAccountKind: String + + """The connection's status (`active`, `needs_reauth`, ...).""" + connectionStatus: String + + """When the connection last synced successfully.""" + lastSyncAt: DateTime + + """The outcome of the connection's most recent sync attempt.""" + lastSyncStatus: String +} + """ Mapping rendered as account rollups — every reporting concept the mapping defines, with the CoA accounts that contribute to it and the diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index 7983c3a9..ad2311d5 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -387,6 +387,8 @@ LineGrowthRequestValuesByPeriodType0, ) from .line_item_metadata_predicate import LineItemMetadataPredicate +from .link_bank_account_request import LinkBankAccountRequest +from .link_bank_account_response import LinkBankAccountResponse from .link_entity_taxonomy_request import LinkEntityTaxonomyRequest from .link_entity_taxonomy_request_basis import LinkEntityTaxonomyRequestBasis from .list_connections_provider_type_0 import ListConnectionsProviderType0 @@ -613,6 +615,12 @@ from .operation_envelope_ledger_entity_response_status import ( OperationEnvelopeLedgerEntityResponseStatus, ) +from .operation_envelope_link_bank_account_response import ( + OperationEnvelopeLinkBankAccountResponse, +) +from .operation_envelope_link_bank_account_response_status import ( + OperationEnvelopeLinkBankAccountResponseStatus, +) from .operation_envelope_live_financial_statement_response import ( OperationEnvelopeLiveFinancialStatementResponse, ) @@ -1409,6 +1417,8 @@ "LineGrowthRequest", "LineGrowthRequestValuesByPeriodType0", "LineItemMetadataPredicate", + "LinkBankAccountRequest", + "LinkBankAccountResponse", "LinkEntityTaxonomyRequest", "LinkEntityTaxonomyRequestBasis", "ListConnectionsProviderType0", @@ -1513,6 +1523,8 @@ "OperationEnvelopeLedgerAgentResponseStatus", "OperationEnvelopeLedgerEntityResponse", "OperationEnvelopeLedgerEntityResponseStatus", + "OperationEnvelopeLinkBankAccountResponse", + "OperationEnvelopeLinkBankAccountResponseStatus", "OperationEnvelopelistPublishListMemberResponse", "OperationEnvelopelistPublishListMemberResponseStatus", "OperationEnvelopeLiveFinancialStatementResponse", diff --git a/robosystems_client/models/link_bank_account_request.py b/robosystems_client/models/link_bank_account_request.py new file mode 100644 index 00000000..1b726642 --- /dev/null +++ b/robosystems_client/models/link_bank_account_request.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="LinkBankAccountRequest") + + +@_attrs_define +class LinkBankAccountRequest: + """Point a bank feed's account at a chart account. + + Name `element_id` for an existing active account, or `entity_id` alone to + create one in that entity's chart. The chart the account is in decides + whose books the feed's lines go into, so this is also how a feed account + is bound to a subsidiary. Lines still in the inbox move with it; posted + entries stay where they were posted. + + Attributes: + connection_id (str): The feed's connection. + account_id (str): The provider's id for the account. + element_id (None | str | Unset): The chart account to link; its chart's entity takes the feed. + entity_id (None | str | Unset): With `element_id`, the entity the account must belong to. Alone, the entity in + whose chart a new account is created for the feed account. + """ + + connection_id: str + account_id: str + element_id: None | str | Unset = UNSET + entity_id: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + connection_id = self.connection_id + + account_id = self.account_id + + element_id: None | str | Unset + if isinstance(self.element_id, Unset): + element_id = UNSET + else: + element_id = self.element_id + + entity_id: None | str | Unset + if isinstance(self.entity_id, Unset): + entity_id = UNSET + else: + entity_id = self.entity_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "connection_id": connection_id, + "account_id": account_id, + } + ) + if element_id is not UNSET: + field_dict["element_id"] = element_id + if entity_id is not UNSET: + field_dict["entity_id"] = entity_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + connection_id = d.pop("connection_id") + + account_id = d.pop("account_id") + + def _parse_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) + + element_id = _parse_element_id(d.pop("element_id", UNSET)) + + 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)) + + link_bank_account_request = cls( + connection_id=connection_id, + account_id=account_id, + element_id=element_id, + entity_id=entity_id, + ) + + link_bank_account_request.additional_properties = d + return link_bank_account_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/robosystems_client/models/link_bank_account_response.py b/robosystems_client/models/link_bank_account_response.py new file mode 100644 index 00000000..a4e63d9c --- /dev/null +++ b/robosystems_client/models/link_bank_account_response.py @@ -0,0 +1,151 @@ +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="LinkBankAccountResponse") + + +@_attrs_define +class LinkBankAccountResponse: + """What `link-bank-account` did. + + Attributes: + connection_id (str): + provider (str): + account_id (str): + element_id (str): The chart account the feed now books to. + previous_element_id (str): + entity_id (str): The entity the feed's lines now belong to. + account_created (bool | Unset): A new account was created in the entity's chart. Default: False. + events_repointed (int | Unset): Inbox lines moved to the new account (posted entries stay). Default: 0. + events_unclassified (int | Unset): Lines returned to `captured`: their classification named an account in the + previous entity's chart. Default: 0. + pairs_across_entities (int | Unset): Open transfer pairs whose two legs now sit on two entities, because only + one leg's account moved. Intercompany; the commit guard refuses them until the other leg follows. Default: 0. + changed (bool | Unset): False when the link already stood. Default: True. + """ + + connection_id: str + provider: str + account_id: str + element_id: str + previous_element_id: str + entity_id: str + account_created: bool | Unset = False + events_repointed: int | Unset = 0 + events_unclassified: int | Unset = 0 + pairs_across_entities: int | Unset = 0 + changed: bool | Unset = True + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + connection_id = self.connection_id + + provider = self.provider + + account_id = self.account_id + + element_id = self.element_id + + previous_element_id = self.previous_element_id + + entity_id = self.entity_id + + account_created = self.account_created + + events_repointed = self.events_repointed + + events_unclassified = self.events_unclassified + + pairs_across_entities = self.pairs_across_entities + + changed = self.changed + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "connection_id": connection_id, + "provider": provider, + "account_id": account_id, + "element_id": element_id, + "previous_element_id": previous_element_id, + "entity_id": entity_id, + } + ) + if account_created is not UNSET: + field_dict["account_created"] = account_created + if events_repointed is not UNSET: + field_dict["events_repointed"] = events_repointed + if events_unclassified is not UNSET: + field_dict["events_unclassified"] = events_unclassified + if pairs_across_entities is not UNSET: + field_dict["pairs_across_entities"] = pairs_across_entities + if changed is not UNSET: + field_dict["changed"] = changed + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + connection_id = d.pop("connection_id") + + provider = d.pop("provider") + + account_id = d.pop("account_id") + + element_id = d.pop("element_id") + + previous_element_id = d.pop("previous_element_id") + + entity_id = d.pop("entity_id") + + account_created = d.pop("account_created", UNSET) + + events_repointed = d.pop("events_repointed", UNSET) + + events_unclassified = d.pop("events_unclassified", UNSET) + + pairs_across_entities = d.pop("pairs_across_entities", UNSET) + + changed = d.pop("changed", UNSET) + + link_bank_account_response = cls( + connection_id=connection_id, + provider=provider, + account_id=account_id, + element_id=element_id, + previous_element_id=previous_element_id, + entity_id=entity_id, + account_created=account_created, + events_repointed=events_repointed, + events_unclassified=events_unclassified, + pairs_across_entities=pairs_across_entities, + changed=changed, + ) + + link_bank_account_response.additional_properties = d + return link_bank_account_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_link_bank_account_response.py b/robosystems_client/models/operation_envelope_link_bank_account_response.py new file mode 100644 index 00000000..614bbcc2 --- /dev/null +++ b/robosystems_client/models/operation_envelope_link_bank_account_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_link_bank_account_response_status import ( + OperationEnvelopeLinkBankAccountResponseStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.link_bank_account_response import LinkBankAccountResponse + + +T = TypeVar("T", bound="OperationEnvelopeLinkBankAccountResponse") + + +@_attrs_define +class OperationEnvelopeLinkBankAccountResponse: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeLinkBankAccountResponseStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (LinkBankAccountResponse | None | 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: OperationEnvelopeLinkBankAccountResponseStatus + at: str + result: LinkBankAccountResponse | None | 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.link_bank_account_response import LinkBankAccountResponse + + 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, LinkBankAccountResponse): + 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.link_bank_account_response import LinkBankAccountResponse + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeLinkBankAccountResponseStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> LinkBankAccountResponse | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = LinkBankAccountResponse.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(LinkBankAccountResponse | None | 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_link_bank_account_response = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_link_bank_account_response.additional_properties = d + return operation_envelope_link_bank_account_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_link_bank_account_response_status.py b/robosystems_client/models/operation_envelope_link_bank_account_response_status.py new file mode 100644 index 00000000..fdb41a6f --- /dev/null +++ b/robosystems_client/models/operation_envelope_link_bank_account_response_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeLinkBankAccountResponseStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index c1890d03..e296d6df 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -3167,6 +3167,75 @@ def test_reopen_period_sends_entity_id(self, mock_op, mock_config, graph_id): "entity_id": "ent_2", } + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_list_bank_accounts(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = { + "bankAccounts": { + "total": 1, + "accounts": [ + { + "id": "elem_chk", + "code": "1010", + "name": "Chase Checking ••1234", + "kind": "bank", + "balanceType": "debit", + "isActive": True, + "entityId": "ent_2", + "entityName": "Cadence", + "source": "plaid", + "connectionId": "conn_plaid", + "institution": "Chase", + "feedAccountId": "acc_1", + "feedAccountName": "Chase Checking ••1234", + "feedAccountKind": "checking", + "connectionStatus": "active", + "lastSyncAt": "2026-10-07T12:00:00+00:00", + "lastSyncStatus": "success", + } + ], + } + } + client = LedgerClient(mock_config) + page = client.list_bank_accounts(graph_id, entity_id="ent_2") + assert mock_execute.call_args[0][2] == {"entityId": "ent_2"} + assert page.total == 1 + assert page.accounts[0].feed_account_id == "acc_1" + assert page.accounts[0].entity_name == "Cadence" + assert page.accounts[0].connection_status == "active" + + @patch("robosystems_client.clients.ledger_client.op_link_bank_account") + def test_link_bank_account_posts_the_body_and_unwraps_the_envelope( + self, mock_op, mock_config, graph_id + ): + moved = { + "connection_id": "conn_plaid", + "provider": "plaid", + "account_id": "acc_1", + "element_id": "elem_sub", + "previous_element_id": "elem_chk", + "entity_id": "ent_2", + "account_created": True, + "events_repointed": 3, + "events_unclassified": 1, + "pairs_across_entities": 0, + "changed": True, + } + mock_op.return_value = _mock_response(_envelope("link-bank-account", moved)) + client = LedgerClient(mock_config) + result = client.link_bank_account( + graph_id, + {"connection_id": "conn_plaid", "account_id": "acc_1", "entity_id": "ent_2"}, + ) + assert mock_op.call_args.kwargs["graph_id"] == graph_id + assert mock_op.call_args.kwargs["body"].to_dict() == { + "connection_id": "conn_plaid", + "account_id": "acc_1", + "entity_id": "ent_2", + } + assert result["element_id"] == "elem_sub" + assert result["account_created"] is True + assert result["events_repointed"] == 3 + @patch("robosystems_client.clients.ledger_client.op_create_entity") def test_create_entity_posts_the_body_and_unwraps_the_envelope( self, mock_op, mock_config, graph_id