From 72708671c3bac44b11c3364abbab52847cce7f00 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Tue, 20 Oct 2026 12:45:07 -0500 Subject: [PATCH] feat: get_aged_balances, get_open_items_for_agent, get_discharge_candidates Claude-Session: https://claude.ai/code/session_013UXnatpnbiixt1ZVHfdZAP --- robosystems_client/clients/ledger_client.py | 81 ++++++- .../graphql/generated/__init__.py | 59 ++++++ .../graphql/generated/aged_payables.py | 96 +++++++++ .../graphql/generated/aged_receivables.py | 96 +++++++++ .../graphql/generated/client.py | 84 ++++++++ .../graphql/generated/discharge_candidates.py | 40 ++++ .../graphql/generated/open_items_for_agent.py | 31 +++ .../graphql/generated/operations.py | 199 ++++++++++++++++++ .../operations/ledger/AgedPayables.graphql | 68 ++++++ .../operations/ledger/AgedReceivables.graphql | 68 ++++++ .../ledger/DischargeCandidates.graphql | 24 +++ .../ledger/OpenItemsForAgent.graphql | 23 ++ tests/test_ledger_client.py | 118 +++++++++++ 13 files changed, 986 insertions(+), 1 deletion(-) create mode 100644 robosystems_client/graphql/generated/aged_payables.py create mode 100644 robosystems_client/graphql/generated/aged_receivables.py create mode 100644 robosystems_client/graphql/generated/discharge_candidates.py create mode 100644 robosystems_client/graphql/generated/open_items_for_agent.py create mode 100644 robosystems_client/graphql/operations/ledger/AgedPayables.graphql create mode 100644 robosystems_client/graphql/operations/ledger/AgedReceivables.graphql create mode 100644 robosystems_client/graphql/operations/ledger/DischargeCandidates.graphql create mode 100644 robosystems_client/graphql/operations/ledger/OpenItemsForAgent.graphql diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index 41c4144..d3f2ba5 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from http import HTTPStatus from pathlib import Path -from typing import Any +from typing import Any, Literal import httpx @@ -468,6 +468,19 @@ ClassificationStats, ClassificationStatsClassificationStats, ) +from ..graphql.generated.aged_payables import AgedPayables, AgedPayablesAgedPayables +from ..graphql.generated.aged_receivables import ( + AgedReceivables, + AgedReceivablesAgedReceivables, +) +from ..graphql.generated.discharge_candidates import ( + DischargeCandidates, + DischargeCandidatesDischargeCandidates, +) +from ..graphql.generated.open_items_for_agent import ( + OpenItemsForAgent, + OpenItemsForAgentOpenItemsForAgent, +) from ..graphql.generated.operations import ( GET_INFORMATION_BLOCK_GQL, GET_LEDGER_ACCOUNT_ROLLUPS_GQL, @@ -513,6 +526,10 @@ MAPPING_CANDIDATES_GQL, FLOW_CANDIDATES_GQL, CLASSIFICATION_STATS_GQL, + AGED_RECEIVABLES_GQL, + AGED_PAYABLES_GQL, + OPEN_ITEMS_FOR_AGENT_GQL, + DISCHARGE_CANDIDATES_GQL, ) from ..models.add_publish_list_members_operation import AddPublishListMembersOperation from ..models.auto_map_elements_operation import AutoMapElementsOperation @@ -2424,6 +2441,68 @@ def get_classification_stats( ) return ClassificationStats.model_validate(data).classification_stats + # ── Receivables and payables ────────────────────────────────────────── + + def get_aged_balances( + self, + graph_id: str, + kind: Literal["receivables", "payables"], + *, + as_of: str | None = None, + entity_id: str | None = None, + scope: Literal["entity", "group"] = "entity", + ) -> AgedReceivablesAgedReceivables | AgedPayablesAgedPayables: + """Receivables or payables aged at a date (``as_of``, ``YYYY-MM-DD``, + default today on the server): totals by days past due, a row per + counterparty, and each entity's open items tied out to its receivables + or payables account (``difference_cents``, never plugged). ``scope`` is + ``entity`` (one entity's books, ``entity_id`` default the group parent) + or ``group`` (the parent and its members consolidated in full, with what + they owe each other in ``intercompany`` and ``pairs``). An item with no + due date of its own is due on receipt and counted in + ``defaulted_due_date_count``. Both kinds return the same fields. + """ + variables = {"asOf": as_of, "entityId": entity_id, "scope": scope} + if kind == "receivables": + data = self._query(graph_id, AGED_RECEIVABLES_GQL, variables) + return AgedReceivables.model_validate(data).aged_receivables + data = self._query(graph_id, AGED_PAYABLES_GQL, variables) + return AgedPayables.model_validate(data).aged_payables + + def get_open_items_for_agent( + self, + graph_id: str, + agent_id: str, + kind: Literal["receivables", "payables"], + *, + as_of: str | None = None, + entity_id: str | None = None, + ) -> list[OpenItemsForAgentOpenItemsForAgent]: + """One counterparty's open invoices (``receivables``) or bills + (``payables``) at a date, oldest first, each with its due date, days + past due and bucket: the drill from an aged row into its documents. + """ + data = self._query( + graph_id, + OPEN_ITEMS_FOR_AGENT_GQL, + {"agentId": agent_id, "kind": kind, "asOf": as_of, "entityId": entity_id}, + ) + return list(OpenItemsForAgent.model_validate(data).open_items_for_agent) + + def get_discharge_candidates( + self, graph_id: str, event_id: str + ) -> DischargeCandidatesDischargeCandidates | None: + """The open invoices or bills a payment or bank line may settle: its own + entity's and counterparty's, open at its date, an exact amount match + first, then the oldest due date. ``classified_to_control`` says the line + went to a receivables or payables account, where a pick is expected. + Record the choice with ``discharge_allocations`` on + ``update_event_block``; nothing settles without one. ``None`` when there + is no such event. + """ + data = self._query(graph_id, DISCHARGE_CANDIDATES_GQL, {"eventId": event_id}) + return DischargeCandidates.model_validate(data).discharge_candidates + # ── Event handlers (DSL handler registry) ──────────────────────────── def create_event_handler( diff --git a/robosystems_client/graphql/generated/__init__.py b/robosystems_client/graphql/generated/__init__.py index 5419898..631934f 100644 --- a/robosystems_client/graphql/generated/__init__.py +++ b/robosystems_client/graphql/generated/__init__.py @@ -1,3 +1,25 @@ +from .aged_payables import ( + AgedPayables, + AgedPayablesAgedPayables, + AgedPayablesAgedPayablesAgents, + AgedPayablesAgedPayablesAgentsBuckets, + AgedPayablesAgedPayablesBuckets, + AgedPayablesAgedPayablesIntercompany, + AgedPayablesAgedPayablesPairs, + AgedPayablesAgedPayablesTieOuts, + AgedPayablesAgedPayablesTieOutsAccounts, +) +from .aged_receivables import ( + AgedReceivables, + AgedReceivablesAgedReceivables, + AgedReceivablesAgedReceivablesAgents, + AgedReceivablesAgedReceivablesAgentsBuckets, + AgedReceivablesAgedReceivablesBuckets, + AgedReceivablesAgedReceivablesIntercompany, + AgedReceivablesAgedReceivablesPairs, + AgedReceivablesAgedReceivablesTieOuts, + AgedReceivablesAgedReceivablesTieOutsAccounts, +) from .base_client import BaseClient from .base_model import BaseModel, Upload from .classification_stats import ( @@ -6,6 +28,11 @@ ClassificationStatsClassificationStatsBySource, ) from .client import Client +from .discharge_candidates import ( + DischargeCandidates, + DischargeCandidatesDischargeCandidates, + DischargeCandidatesDischargeCandidatesCandidates, +) from .enums import ReportDownloadFormat, ReportLifecycle from .exceptions import ( GraphQLClientError, @@ -351,8 +378,12 @@ ListLibraryTaxonomyArcsLibraryTaxonomyArcs, ) from .mapping_candidates import MappingCandidates, MappingCandidatesMappingCandidates +from .open_items_for_agent import OpenItemsForAgent, OpenItemsForAgentOpenItemsForAgent from .operations import ( + AGED_PAYABLES_GQL, + AGED_RECEIVABLES_GQL, CLASSIFICATION_STATS_GQL, + DISCHARGE_CANDIDATES_GQL, FLOW_CANDIDATES_GQL, GET_INFORMATION_BLOCK_GQL, GET_INVESTOR_HOLDINGS_GQL, @@ -412,6 +443,7 @@ LIST_LIBRARY_TAXONOMIES_GQL, LIST_LIBRARY_TAXONOMY_ARCS_GQL, MAPPING_CANDIDATES_GQL, + OPEN_ITEMS_FOR_AGENT_GQL, SEARCH_LIBRARY_ELEMENTS_GQL, ) from .search_library_elements import ( @@ -422,6 +454,26 @@ ) __all__ = [ + "AGED_PAYABLES_GQL", + "AGED_RECEIVABLES_GQL", + "AgedPayables", + "AgedPayablesAgedPayables", + "AgedPayablesAgedPayablesAgents", + "AgedPayablesAgedPayablesAgentsBuckets", + "AgedPayablesAgedPayablesBuckets", + "AgedPayablesAgedPayablesIntercompany", + "AgedPayablesAgedPayablesPairs", + "AgedPayablesAgedPayablesTieOuts", + "AgedPayablesAgedPayablesTieOutsAccounts", + "AgedReceivables", + "AgedReceivablesAgedReceivables", + "AgedReceivablesAgedReceivablesAgents", + "AgedReceivablesAgedReceivablesAgentsBuckets", + "AgedReceivablesAgedReceivablesBuckets", + "AgedReceivablesAgedReceivablesIntercompany", + "AgedReceivablesAgedReceivablesPairs", + "AgedReceivablesAgedReceivablesTieOuts", + "AgedReceivablesAgedReceivablesTieOutsAccounts", "BaseClient", "BaseModel", "CLASSIFICATION_STATS_GQL", @@ -429,6 +481,10 @@ "ClassificationStatsClassificationStats", "ClassificationStatsClassificationStatsBySource", "Client", + "DISCHARGE_CANDIDATES_GQL", + "DischargeCandidates", + "DischargeCandidatesDischargeCandidates", + "DischargeCandidatesDischargeCandidatesCandidates", "FLOW_CANDIDATES_GQL", "FlowCandidates", "FlowCandidatesFlowCandidates", @@ -742,6 +798,9 @@ "MAPPING_CANDIDATES_GQL", "MappingCandidates", "MappingCandidatesMappingCandidates", + "OPEN_ITEMS_FOR_AGENT_GQL", + "OpenItemsForAgent", + "OpenItemsForAgentOpenItemsForAgent", "ReportDownloadFormat", "ReportLifecycle", "SEARCH_LIBRARY_ELEMENTS_GQL", diff --git a/robosystems_client/graphql/generated/aged_payables.py b/robosystems_client/graphql/generated/aged_payables.py new file mode 100644 index 0000000..b6efa26 --- /dev/null +++ b/robosystems_client/graphql/generated/aged_payables.py @@ -0,0 +1,96 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class AgedPayables(BaseModel): + aged_payables: "AgedPayablesAgedPayables" = Field(alias="agedPayables") + + +class AgedPayablesAgedPayables(BaseModel): + kind: str + as_of: str = Field(alias="asOf") + scope: str + entity_id: Optional[str] = Field(alias="entityId") + member_entity_ids: list[str] = Field(alias="memberEntityIds") + buckets: "AgedPayablesAgedPayablesBuckets" + agents: list["AgedPayablesAgedPayablesAgents"] + defaulted_due_date_count: int = Field(alias="defaultedDueDateCount") + intercompany: list["AgedPayablesAgedPayablesIntercompany"] + pairs: list["AgedPayablesAgedPayablesPairs"] + tie_outs: list["AgedPayablesAgedPayablesTieOuts"] = Field(alias="tieOuts") + + +class AgedPayablesAgedPayablesBuckets(BaseModel): + current_cents: int = Field(alias="currentCents") + one_to_thirty_cents: int = Field(alias="oneToThirtyCents") + thirty_one_to_sixty_cents: int = Field(alias="thirtyOneToSixtyCents") + sixty_one_to_ninety_cents: int = Field(alias="sixtyOneToNinetyCents") + over_ninety_cents: int = Field(alias="overNinetyCents") + unapplied_credit_cents: int = Field(alias="unappliedCreditCents") + total_cents: int = Field(alias="totalCents") + + +class AgedPayablesAgedPayablesAgents(BaseModel): + agent_id: Optional[str] = Field(alias="agentId") + agent_name: Optional[str] = Field(alias="agentName") + open_item_count: int = Field(alias="openItemCount") + oldest_due_date: Optional[str] = Field(alias="oldestDueDate") + buckets: "AgedPayablesAgedPayablesAgentsBuckets" + + +class AgedPayablesAgedPayablesAgentsBuckets(BaseModel): + current_cents: int = Field(alias="currentCents") + one_to_thirty_cents: int = Field(alias="oneToThirtyCents") + thirty_one_to_sixty_cents: int = Field(alias="thirtyOneToSixtyCents") + sixty_one_to_ninety_cents: int = Field(alias="sixtyOneToNinetyCents") + over_ninety_cents: int = Field(alias="overNinetyCents") + unapplied_credit_cents: int = Field(alias="unappliedCreditCents") + total_cents: int = Field(alias="totalCents") + + +class AgedPayablesAgedPayablesIntercompany(BaseModel): + event_id: str = Field(alias="eventId") + entity_id: str = Field(alias="entityId") + agent_id: Optional[str] = Field(alias="agentId") + occurred_on: str = Field(alias="occurredOn") + document_number: Optional[str] = Field(alias="documentNumber") + description: Optional[str] + original_cents: int = Field(alias="originalCents") + settled_cents: int = Field(alias="settledCents") + open_cents: int = Field(alias="openCents") + currency: str + due_date: str = Field(alias="dueDate") + due_date_basis: str = Field(alias="dueDateBasis") + days_past_due: int = Field(alias="daysPastDue") + bucket: str + + +class AgedPayablesAgedPayablesPairs(BaseModel): + creditor_entity_id: str = Field(alias="creditorEntityId") + debtor_entity_id: str = Field(alias="debtorEntityId") + receivable_cents: int = Field(alias="receivableCents") + payable_cents: int = Field(alias="payableCents") + difference_cents: int = Field(alias="differenceCents") + + +class AgedPayablesAgedPayablesTieOuts(BaseModel): + entity_id: str = Field(alias="entityId") + accounts: list["AgedPayablesAgedPayablesTieOutsAccounts"] + control_balance_cents: Optional[int] = Field(alias="controlBalanceCents") + open_items_cents: int = Field(alias="openItemsCents") + difference_cents: Optional[int] = Field(alias="differenceCents") + + +class AgedPayablesAgedPayablesTieOutsAccounts(BaseModel): + element_id: str = Field(alias="elementId") + code: Optional[str] + name: str + + +AgedPayables.model_rebuild() +AgedPayablesAgedPayables.model_rebuild() +AgedPayablesAgedPayablesAgents.model_rebuild() +AgedPayablesAgedPayablesTieOuts.model_rebuild() diff --git a/robosystems_client/graphql/generated/aged_receivables.py b/robosystems_client/graphql/generated/aged_receivables.py new file mode 100644 index 0000000..86ba9d1 --- /dev/null +++ b/robosystems_client/graphql/generated/aged_receivables.py @@ -0,0 +1,96 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class AgedReceivables(BaseModel): + aged_receivables: "AgedReceivablesAgedReceivables" = Field(alias="agedReceivables") + + +class AgedReceivablesAgedReceivables(BaseModel): + kind: str + as_of: str = Field(alias="asOf") + scope: str + entity_id: Optional[str] = Field(alias="entityId") + member_entity_ids: list[str] = Field(alias="memberEntityIds") + buckets: "AgedReceivablesAgedReceivablesBuckets" + agents: list["AgedReceivablesAgedReceivablesAgents"] + defaulted_due_date_count: int = Field(alias="defaultedDueDateCount") + intercompany: list["AgedReceivablesAgedReceivablesIntercompany"] + pairs: list["AgedReceivablesAgedReceivablesPairs"] + tie_outs: list["AgedReceivablesAgedReceivablesTieOuts"] = Field(alias="tieOuts") + + +class AgedReceivablesAgedReceivablesBuckets(BaseModel): + current_cents: int = Field(alias="currentCents") + one_to_thirty_cents: int = Field(alias="oneToThirtyCents") + thirty_one_to_sixty_cents: int = Field(alias="thirtyOneToSixtyCents") + sixty_one_to_ninety_cents: int = Field(alias="sixtyOneToNinetyCents") + over_ninety_cents: int = Field(alias="overNinetyCents") + unapplied_credit_cents: int = Field(alias="unappliedCreditCents") + total_cents: int = Field(alias="totalCents") + + +class AgedReceivablesAgedReceivablesAgents(BaseModel): + agent_id: Optional[str] = Field(alias="agentId") + agent_name: Optional[str] = Field(alias="agentName") + open_item_count: int = Field(alias="openItemCount") + oldest_due_date: Optional[str] = Field(alias="oldestDueDate") + buckets: "AgedReceivablesAgedReceivablesAgentsBuckets" + + +class AgedReceivablesAgedReceivablesAgentsBuckets(BaseModel): + current_cents: int = Field(alias="currentCents") + one_to_thirty_cents: int = Field(alias="oneToThirtyCents") + thirty_one_to_sixty_cents: int = Field(alias="thirtyOneToSixtyCents") + sixty_one_to_ninety_cents: int = Field(alias="sixtyOneToNinetyCents") + over_ninety_cents: int = Field(alias="overNinetyCents") + unapplied_credit_cents: int = Field(alias="unappliedCreditCents") + total_cents: int = Field(alias="totalCents") + + +class AgedReceivablesAgedReceivablesIntercompany(BaseModel): + event_id: str = Field(alias="eventId") + entity_id: str = Field(alias="entityId") + agent_id: Optional[str] = Field(alias="agentId") + occurred_on: str = Field(alias="occurredOn") + document_number: Optional[str] = Field(alias="documentNumber") + description: Optional[str] + original_cents: int = Field(alias="originalCents") + settled_cents: int = Field(alias="settledCents") + open_cents: int = Field(alias="openCents") + currency: str + due_date: str = Field(alias="dueDate") + due_date_basis: str = Field(alias="dueDateBasis") + days_past_due: int = Field(alias="daysPastDue") + bucket: str + + +class AgedReceivablesAgedReceivablesPairs(BaseModel): + creditor_entity_id: str = Field(alias="creditorEntityId") + debtor_entity_id: str = Field(alias="debtorEntityId") + receivable_cents: int = Field(alias="receivableCents") + payable_cents: int = Field(alias="payableCents") + difference_cents: int = Field(alias="differenceCents") + + +class AgedReceivablesAgedReceivablesTieOuts(BaseModel): + entity_id: str = Field(alias="entityId") + accounts: list["AgedReceivablesAgedReceivablesTieOutsAccounts"] + control_balance_cents: Optional[int] = Field(alias="controlBalanceCents") + open_items_cents: int = Field(alias="openItemsCents") + difference_cents: Optional[int] = Field(alias="differenceCents") + + +class AgedReceivablesAgedReceivablesTieOutsAccounts(BaseModel): + element_id: str = Field(alias="elementId") + code: Optional[str] + name: str + + +AgedReceivables.model_rebuild() +AgedReceivablesAgedReceivables.model_rebuild() +AgedReceivablesAgedReceivablesAgents.model_rebuild() +AgedReceivablesAgedReceivablesTieOuts.model_rebuild() diff --git a/robosystems_client/graphql/generated/client.py b/robosystems_client/graphql/generated/client.py index 8ee0283..71202e3 100644 --- a/robosystems_client/graphql/generated/client.py +++ b/robosystems_client/graphql/generated/client.py @@ -1,8 +1,11 @@ from typing import Any, Optional, Union +from .aged_payables import AgedPayables +from .aged_receivables import AgedReceivables from .base_client import BaseClient from .base_model import UNSET, UnsetType from .classification_stats import ClassificationStats +from .discharge_candidates import DischargeCandidates from .enums import ReportDownloadFormat, ReportLifecycle from .flow_candidates import FlowCandidates from .get_information_block import GetInformationBlock @@ -63,8 +66,12 @@ from .list_library_taxonomies import ListLibraryTaxonomies from .list_library_taxonomy_arcs import ListLibraryTaxonomyArcs from .mapping_candidates import MappingCandidates +from .open_items_for_agent import OpenItemsForAgent from .operations import ( + AGED_PAYABLES_GQL, + AGED_RECEIVABLES_GQL, CLASSIFICATION_STATS_GQL, + DISCHARGE_CANDIDATES_GQL, FLOW_CANDIDATES_GQL, GET_INFORMATION_BLOCK_GQL, GET_INVESTOR_HOLDINGS_GQL, @@ -124,6 +131,7 @@ LIST_LIBRARY_TAXONOMIES_GQL, LIST_LIBRARY_TAXONOMY_ARCS_GQL, MAPPING_CANDIDATES_GQL, + OPEN_ITEMS_FOR_AGENT_GQL, SEARCH_LIBRARY_ELEMENTS_GQL, ) from .search_library_elements import SearchLibraryElements @@ -249,6 +257,48 @@ def list_investor_securities( data = self.get_data(response) return ListInvestorSecurities.model_validate(data) + def aged_payables( + self, + scope: str, + as_of: Union[Optional[str], UnsetType] = UNSET, + entity_id: Union[Optional[str], UnsetType] = UNSET, + **kwargs: Any, + ) -> AgedPayables: + variables: dict[str, object] = { + "asOf": as_of, + "entityId": entity_id, + "scope": scope, + } + response = self.execute( + query=AGED_PAYABLES_GQL, + operation_name="AgedPayables", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return AgedPayables.model_validate(data) + + def aged_receivables( + self, + scope: str, + as_of: Union[Optional[str], UnsetType] = UNSET, + entity_id: Union[Optional[str], UnsetType] = UNSET, + **kwargs: Any, + ) -> AgedReceivables: + variables: dict[str, object] = { + "asOf": as_of, + "entityId": entity_id, + "scope": scope, + } + response = self.execute( + query=AGED_RECEIVABLES_GQL, + operation_name="AgedReceivables", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return AgedReceivables.model_validate(data) + def classification_stats( self, period: str, entity_id: Union[Optional[str], UnsetType] = UNSET, **kwargs: Any ) -> ClassificationStats: @@ -262,6 +312,17 @@ def classification_stats( data = self.get_data(response) return ClassificationStats.model_validate(data) + def discharge_candidates(self, event_id: str, **kwargs: Any) -> DischargeCandidates: + variables: dict[str, object] = {"eventId": event_id} + response = self.execute( + query=DISCHARGE_CANDIDATES_GQL, + operation_name="DischargeCandidates", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return DischargeCandidates.model_validate(data) + def flow_candidates( self, entity_id: Union[Optional[str], UnsetType] = UNSET, **kwargs: Any ) -> FlowCandidates: @@ -1000,6 +1061,29 @@ def mapping_candidates( data = self.get_data(response) return MappingCandidates.model_validate(data) + def open_items_for_agent( + self, + agent_id: str, + kind: str, + as_of: Union[Optional[str], UnsetType] = UNSET, + entity_id: Union[Optional[str], UnsetType] = UNSET, + **kwargs: Any, + ) -> OpenItemsForAgent: + variables: dict[str, object] = { + "agentId": agent_id, + "kind": kind, + "asOf": as_of, + "entityId": entity_id, + } + response = self.execute( + query=OPEN_ITEMS_FOR_AGENT_GQL, + operation_name="OpenItemsForAgent", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return OpenItemsForAgent.model_validate(data) + def get_library_element( self, id: Union[Optional[str], UnsetType] = UNSET, diff --git a/robosystems_client/graphql/generated/discharge_candidates.py b/robosystems_client/graphql/generated/discharge_candidates.py new file mode 100644 index 0000000..9d0fcb9 --- /dev/null +++ b/robosystems_client/graphql/generated/discharge_candidates.py @@ -0,0 +1,40 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class DischargeCandidates(BaseModel): + discharge_candidates: Optional["DischargeCandidatesDischargeCandidates"] = Field( + alias="dischargeCandidates" + ) + + +class DischargeCandidatesDischargeCandidates(BaseModel): + event_id: str = Field(alias="eventId") + kind: Optional[str] + amount_cents: int = Field(alias="amountCents") + classified_to_control: bool = Field(alias="classifiedToControl") + candidates: list["DischargeCandidatesDischargeCandidatesCandidates"] + + +class DischargeCandidatesDischargeCandidatesCandidates(BaseModel): + event_id: str = Field(alias="eventId") + entity_id: str = Field(alias="entityId") + agent_id: Optional[str] = Field(alias="agentId") + occurred_on: str = Field(alias="occurredOn") + document_number: Optional[str] = Field(alias="documentNumber") + description: Optional[str] + original_cents: int = Field(alias="originalCents") + settled_cents: int = Field(alias="settledCents") + open_cents: int = Field(alias="openCents") + currency: str + due_date: str = Field(alias="dueDate") + due_date_basis: str = Field(alias="dueDateBasis") + days_past_due: int = Field(alias="daysPastDue") + bucket: str + + +DischargeCandidates.model_rebuild() +DischargeCandidatesDischargeCandidates.model_rebuild() diff --git a/robosystems_client/graphql/generated/open_items_for_agent.py b/robosystems_client/graphql/generated/open_items_for_agent.py new file mode 100644 index 0000000..628d7cf --- /dev/null +++ b/robosystems_client/graphql/generated/open_items_for_agent.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class OpenItemsForAgent(BaseModel): + open_items_for_agent: list["OpenItemsForAgentOpenItemsForAgent"] = Field( + alias="openItemsForAgent" + ) + + +class OpenItemsForAgentOpenItemsForAgent(BaseModel): + event_id: str = Field(alias="eventId") + entity_id: str = Field(alias="entityId") + agent_id: Optional[str] = Field(alias="agentId") + occurred_on: str = Field(alias="occurredOn") + document_number: Optional[str] = Field(alias="documentNumber") + description: Optional[str] + original_cents: int = Field(alias="originalCents") + settled_cents: int = Field(alias="settledCents") + open_cents: int = Field(alias="openCents") + currency: str + due_date: str = Field(alias="dueDate") + due_date_basis: str = Field(alias="dueDateBasis") + days_past_due: int = Field(alias="daysPastDue") + bucket: str + + +OpenItemsForAgent.model_rebuild() diff --git a/robosystems_client/graphql/generated/operations.py b/robosystems_client/graphql/generated/operations.py index 0b5eac5..11cf276 100644 --- a/robosystems_client/graphql/generated/operations.py +++ b/robosystems_client/graphql/generated/operations.py @@ -1,5 +1,8 @@ __all__ = [ + "AGED_PAYABLES_GQL", + "AGED_RECEIVABLES_GQL", "CLASSIFICATION_STATS_GQL", + "DISCHARGE_CANDIDATES_GQL", "FLOW_CANDIDATES_GQL", "GET_INFORMATION_BLOCK_GQL", "GET_INVESTOR_HOLDINGS_GQL", @@ -59,6 +62,7 @@ "LIST_LIBRARY_TAXONOMIES_GQL", "LIST_LIBRARY_TAXONOMY_ARCS_GQL", "MAPPING_CANDIDATES_GQL", + "OPEN_ITEMS_FOR_AGENT_GQL", "SEARCH_LIBRARY_ELEMENTS_GQL", ] @@ -281,6 +285,148 @@ } """ +AGED_PAYABLES_GQL = """ +query AgedPayables($asOf: Date, $entityId: String, $scope: String! = "entity") { + agedPayables(asOf: $asOf, entityId: $entityId, scope: $scope) { + kind + asOf + scope + entityId + memberEntityIds + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + agents { + agentId + agentName + openItemCount + oldestDueDate + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + } + defaultedDueDateCount + intercompany { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + pairs { + creditorEntityId + debtorEntityId + receivableCents + payableCents + differenceCents + } + tieOuts { + entityId + accounts { + elementId + code + name + } + controlBalanceCents + openItemsCents + differenceCents + } + } +} +""" + +AGED_RECEIVABLES_GQL = """ +query AgedReceivables($asOf: Date, $entityId: String, $scope: String! = "entity") { + agedReceivables(asOf: $asOf, entityId: $entityId, scope: $scope) { + kind + asOf + scope + entityId + memberEntityIds + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + agents { + agentId + agentName + openItemCount + oldestDueDate + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + } + defaultedDueDateCount + intercompany { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + pairs { + creditorEntityId + debtorEntityId + receivableCents + payableCents + differenceCents + } + tieOuts { + entityId + accounts { + elementId + code + name + } + controlBalanceCents + openItemsCents + differenceCents + } + } +} +""" + CLASSIFICATION_STATS_GQL = """ query ClassificationStats($period: String!, $entityId: String) { classificationStats(period: $period, entityId: $entityId) { @@ -306,6 +452,33 @@ } """ +DISCHARGE_CANDIDATES_GQL = """ +query DischargeCandidates($eventId: String!) { + dischargeCandidates(eventId: $eventId) { + eventId + kind + amountCents + classifiedToControl + candidates { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + } +} +""" + FLOW_CANDIDATES_GQL = """ query FlowCandidates($entityId: String) { flowCandidates(entityId: $entityId) { @@ -2006,6 +2179,32 @@ } """ +OPEN_ITEMS_FOR_AGENT_GQL = """ +query OpenItemsForAgent($agentId: String!, $kind: String!, $asOf: Date, $entityId: String) { + openItemsForAgent( + agentId: $agentId + kind: $kind + asOf: $asOf + entityId: $entityId + ) { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } +} +""" + GET_LIBRARY_ELEMENT_GQL = """ query GetLibraryElement($id: ID, $qname: String) { libraryElement(id: $id, qname: $qname) { diff --git a/robosystems_client/graphql/operations/ledger/AgedPayables.graphql b/robosystems_client/graphql/operations/ledger/AgedPayables.graphql new file mode 100644 index 0000000..635ff7f --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/AgedPayables.graphql @@ -0,0 +1,68 @@ +query AgedPayables($asOf: Date, $entityId: String, $scope: String! = "entity") { + agedPayables(asOf: $asOf, entityId: $entityId, scope: $scope) { + kind + asOf + scope + entityId + memberEntityIds + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + agents { + agentId + agentName + openItemCount + oldestDueDate + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + } + defaultedDueDateCount + intercompany { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + pairs { + creditorEntityId + debtorEntityId + receivableCents + payableCents + differenceCents + } + tieOuts { + entityId + accounts { + elementId + code + name + } + controlBalanceCents + openItemsCents + differenceCents + } + } +} diff --git a/robosystems_client/graphql/operations/ledger/AgedReceivables.graphql b/robosystems_client/graphql/operations/ledger/AgedReceivables.graphql new file mode 100644 index 0000000..e7576bb --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/AgedReceivables.graphql @@ -0,0 +1,68 @@ +query AgedReceivables($asOf: Date, $entityId: String, $scope: String! = "entity") { + agedReceivables(asOf: $asOf, entityId: $entityId, scope: $scope) { + kind + asOf + scope + entityId + memberEntityIds + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + agents { + agentId + agentName + openItemCount + oldestDueDate + buckets { + currentCents + oneToThirtyCents + thirtyOneToSixtyCents + sixtyOneToNinetyCents + overNinetyCents + unappliedCreditCents + totalCents + } + } + defaultedDueDateCount + intercompany { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + pairs { + creditorEntityId + debtorEntityId + receivableCents + payableCents + differenceCents + } + tieOuts { + entityId + accounts { + elementId + code + name + } + controlBalanceCents + openItemsCents + differenceCents + } + } +} diff --git a/robosystems_client/graphql/operations/ledger/DischargeCandidates.graphql b/robosystems_client/graphql/operations/ledger/DischargeCandidates.graphql new file mode 100644 index 0000000..a9f0fc9 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/DischargeCandidates.graphql @@ -0,0 +1,24 @@ +query DischargeCandidates($eventId: String!) { + dischargeCandidates(eventId: $eventId) { + eventId + kind + amountCents + classifiedToControl + candidates { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } + } +} diff --git a/robosystems_client/graphql/operations/ledger/OpenItemsForAgent.graphql b/robosystems_client/graphql/operations/ledger/OpenItemsForAgent.graphql new file mode 100644 index 0000000..9bfdf92 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/OpenItemsForAgent.graphql @@ -0,0 +1,23 @@ +query OpenItemsForAgent( + $agentId: String! + $kind: String! + $asOf: Date + $entityId: String +) { + openItemsForAgent(agentId: $agentId, kind: $kind, asOf: $asOf, entityId: $entityId) { + eventId + entityId + agentId + occurredOn + documentNumber + description + originalCents + settledCents + openCents + currency + dueDate + dueDateBasis + daysPastDue + bucket + } +} diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index e073745..7895d94 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -3087,6 +3087,124 @@ def test_classification_stats_default_to_the_parent_and_may_be_null( assert mock_execute.call_args[0][2] == {"period": "2026-10"} +_BUCKETS = { + "currentCents": 0, + "oneToThirtyCents": 0, + "thirtyOneToSixtyCents": 10000, + "sixtyOneToNinetyCents": 0, + "overNinetyCents": 0, + "unappliedCreditCents": 0, + "totalCents": 10000, +} + +_OPEN_ITEM = { + "eventId": "evt_1", + "entityId": "ent_parent", + "agentId": "agt_acme", + "occurredOn": "2026-08-15", + "documentNumber": "1042", + "description": None, + "originalCents": 10000, + "settledCents": 0, + "openCents": 10000, + "currency": "USD", + "dueDate": "2026-08-15", + "dueDateBasis": "defaulted", + "daysPastDue": 46, + "bucket": "thirty_one_to_sixty", +} + + +def _aged(kind: str) -> dict: + return { + "kind": kind, + "asOf": "2026-09-30", + "scope": "group", + "entityId": "ent_parent", + "memberEntityIds": ["ent_parent", "ent_sub"], + "buckets": _BUCKETS, + "agents": [ + { + "agentId": "agt_acme", + "agentName": "Acme", + "openItemCount": 1, + "oldestDueDate": "2026-08-15", + "buckets": _BUCKETS, + } + ], + "defaultedDueDateCount": 1, + "intercompany": [], + "pairs": [], + "tieOuts": [ + { + "entityId": "ent_parent", + "accounts": [{"elementId": "el_ar", "code": "1100", "name": "A/R"}], + "controlBalanceCents": 10000, + "openItemsCents": 10000, + "differenceCents": 0, + } + ], + } + + +@pytest.mark.unit +class TestReceivablesAndPayables: + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_aged_receivables_at_a_date_for_the_group( + self, mock_execute, mock_config, graph_id + ): + mock_execute.return_value = {"agedReceivables": _aged("receivables")} + client = LedgerClient(mock_config) + aged = client.get_aged_balances( + graph_id, "receivables", as_of="2026-09-30", scope="group" + ) + assert aged.buckets.thirty_one_to_sixty_cents == 10000 + assert aged.agents[0].agent_name == "Acme" + assert aged.tie_outs[0].difference_cents == 0 + assert "agedReceivables" in mock_execute.call_args[0][1] + assert mock_execute.call_args[0][2] == {"asOf": "2026-09-30", "scope": "group"} + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_aged_payables_read_their_own_field( + self, mock_execute, mock_config, graph_id + ): + mock_execute.return_value = {"agedPayables": _aged("payables")} + client = LedgerClient(mock_config) + assert client.get_aged_balances(graph_id, "payables").kind == "payables" + assert "agedPayables" in mock_execute.call_args[0][1] + assert mock_execute.call_args[0][2] == {"scope": "entity"} + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_one_counterpartys_open_items(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = {"openItemsForAgent": [_OPEN_ITEM]} + client = LedgerClient(mock_config) + [item] = client.get_open_items_for_agent(graph_id, "agt_acme", "receivables") + assert (item.open_cents, item.due_date_basis) == (10000, "defaulted") + assert mock_execute.call_args[0][2] == { + "agentId": "agt_acme", + "kind": "receivables", + } + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_discharge_candidates_or_none(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = { + "dischargeCandidates": { + "eventId": "evt_line", + "kind": "receivables", + "amountCents": 10000, + "classifiedToControl": True, + "candidates": [_OPEN_ITEM], + } + } + client = LedgerClient(mock_config) + candidates = client.get_discharge_candidates(graph_id, "evt_line") + assert candidates is not None + assert candidates.classified_to_control + assert candidates.candidates[0].event_id == "evt_1" + mock_execute.return_value = {"dischargeCandidates": None} + assert client.get_discharge_candidates(graph_id, "evt_x") is None + + # ── Compute metrics ────────────────────────────────────────────────────