diff --git a/robosystems_client/api/connections/oauth_callback.py b/robosystems_client/api/connections/oauth_callback.py index 422677f..b9bcdd1 100644 --- a/robosystems_client/api/connections/oauth_callback.py +++ b/robosystems_client/api/connections/oauth_callback.py @@ -108,8 +108,10 @@ def sync_detailed( Completes the OAuth authorization flow after provider redirect. Exchanges the authorization code for tokens, stores them, and triggers an initial sync. This is a redirect target — not typically called - directly. Plaid: pass Link's public_token as `code`; a bank already connected to the graph is - refused (409 DUPLICATE_BANK_CONNECTION). + directly. QuickBooks: only the company that built the graph's books may connect to them; another + company is refused (409 OTHER_COMPANY_BOOKS) before any token is issued. Plaid: pass Link's + public_token as `code`; a bank already connected to the graph is refused (409 + DUPLICATE_BANK_CONNECTION). Args: graph_id (str): @@ -148,8 +150,10 @@ def sync( Completes the OAuth authorization flow after provider redirect. Exchanges the authorization code for tokens, stores them, and triggers an initial sync. This is a redirect target — not typically called - directly. Plaid: pass Link's public_token as `code`; a bank already connected to the graph is - refused (409 DUPLICATE_BANK_CONNECTION). + directly. QuickBooks: only the company that built the graph's books may connect to them; another + company is refused (409 OTHER_COMPANY_BOOKS) before any token is issued. Plaid: pass Link's + public_token as `code`; a bank already connected to the graph is refused (409 + DUPLICATE_BANK_CONNECTION). Args: graph_id (str): @@ -183,8 +187,10 @@ async def asyncio_detailed( Completes the OAuth authorization flow after provider redirect. Exchanges the authorization code for tokens, stores them, and triggers an initial sync. This is a redirect target — not typically called - directly. Plaid: pass Link's public_token as `code`; a bank already connected to the graph is - refused (409 DUPLICATE_BANK_CONNECTION). + directly. QuickBooks: only the company that built the graph's books may connect to them; another + company is refused (409 OTHER_COMPANY_BOOKS) before any token is issued. Plaid: pass Link's + public_token as `code`; a bank already connected to the graph is refused (409 + DUPLICATE_BANK_CONNECTION). Args: graph_id (str): @@ -221,8 +227,10 @@ async def asyncio( Completes the OAuth authorization flow after provider redirect. Exchanges the authorization code for tokens, stores them, and triggers an initial sync. This is a redirect target — not typically called - directly. Plaid: pass Link's public_token as `code`; a bank already connected to the graph is - refused (409 DUPLICATE_BANK_CONNECTION). + directly. QuickBooks: only the company that built the graph's books may connect to them; another + company is refused (409 OTHER_COMPANY_BOOKS) before any token is issued. Plaid: pass Link's + public_token as `code`; a bank already connected to the graph is refused (409 + DUPLICATE_BANK_CONNECTION). Args: graph_id (str): diff --git a/robosystems_client/api/robo_ledger_fiscal_close/rebuild_schedule.py b/robosystems_client/api/robo_ledger_fiscal_close/rebuild_schedule.py index 2b387a7..b6aad38 100644 --- a/robosystems_client/api/robo_ledger_fiscal_close/rebuild_schedule.py +++ b/robosystems_client/api/robo_ledger_fiscal_close/rebuild_schedule.py @@ -138,8 +138,9 @@ def sync_detailed( monthly_amount / period bounds on the Structure's metadata). The historical-vs-in-scope split is re-derived from the CURRENT fiscal - calendar `closed_through`, so a rebuild re-scopes the schedule to - today's close state. Use this to pick up a fixed generator (e.g. the + calendar (months through its `closed_through`, or before its first + month, are history), so a rebuild re-scopes the schedule to today's + close state. Use this to pick up a fixed generator (e.g. the roll-forward direction fix) without orphaning obligations. Raises: @@ -197,8 +198,9 @@ def sync( monthly_amount / period bounds on the Structure's metadata). The historical-vs-in-scope split is re-derived from the CURRENT fiscal - calendar `closed_through`, so a rebuild re-scopes the schedule to - today's close state. Use this to pick up a fixed generator (e.g. the + calendar (months through its `closed_through`, or before its first + month, are history), so a rebuild re-scopes the schedule to today's + close state. Use this to pick up a fixed generator (e.g. the roll-forward direction fix) without orphaning obligations. Raises: @@ -251,8 +253,9 @@ async def asyncio_detailed( monthly_amount / period bounds on the Structure's metadata). The historical-vs-in-scope split is re-derived from the CURRENT fiscal - calendar `closed_through`, so a rebuild re-scopes the schedule to - today's close state. Use this to pick up a fixed generator (e.g. the + calendar (months through its `closed_through`, or before its first + month, are history), so a rebuild re-scopes the schedule to today's + close state. Use this to pick up a fixed generator (e.g. the roll-forward direction fix) without orphaning obligations. Raises: @@ -308,8 +311,9 @@ async def asyncio( monthly_amount / period bounds on the Structure's metadata). The historical-vs-in-scope split is re-derived from the CURRENT fiscal - calendar `closed_through`, so a rebuild re-scopes the schedule to - today's close state. Use this to pick up a fixed generator (e.g. the + calendar (months through its `closed_through`, or before its first + month, are history), so a rebuild re-scopes the schedule to today's + close state. Use this to pick up a fixed generator (e.g. the roll-forward direction fix) without orphaning obligations. Raises: diff --git a/robosystems_client/api/robo_ledger_ledger_events/accept_suggestions.py b/robosystems_client/api/robo_ledger_ledger_events/accept_suggestions.py new file mode 100644 index 0000000..9001641 --- /dev/null +++ b/robosystems_client/api/robo_ledger_ledger_events/accept_suggestions.py @@ -0,0 +1,282 @@ +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.accept_suggestions_request import AcceptSuggestionsRequest +from ...models.error_response import ErrorResponse +from ...models.operation_envelope_accept_suggestions_response import ( + OperationEnvelopeAcceptSuggestionsResponse, +) +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + graph_id: str, + *, + body: AcceptSuggestionsRequest, + 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/accept-suggestions".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 | OperationEnvelopeAcceptSuggestionsResponse | None: + if response.status_code == 200: + response_200 = OperationEnvelopeAcceptSuggestionsResponse.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 | OperationEnvelopeAcceptSuggestionsResponse]: + 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: AcceptSuggestionsRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeAcceptSuggestionsResponse]: + """Accept Suggestions + + Take the suggestion on every open bank line the filters select, oldest first: each is classified to + its `suggested_element_id` through the same update a single line goes through, and posted too with + `commit: true`. Select by `source`, `agent_id`, `suggestion_source` (`agent_default` for the learned + ones), a date range, or explicit `event_ids` from a multi-select. A line it cannot take — no + suggestion to take, already classified or posted, a closed month, a choice the handler refuses — + comes back `skipped` with the reason, never forced. `dry_run: true` reports the counts without + writing; `limit` bounds a call and `truncated` says more matched. + + **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 (AcceptSuggestionsRequest): Take the suggestion on every open bank line the filters + select. + + 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 | OperationEnvelopeAcceptSuggestionsResponse] + """ + + 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: AcceptSuggestionsRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeAcceptSuggestionsResponse | None: + """Accept Suggestions + + Take the suggestion on every open bank line the filters select, oldest first: each is classified to + its `suggested_element_id` through the same update a single line goes through, and posted too with + `commit: true`. Select by `source`, `agent_id`, `suggestion_source` (`agent_default` for the learned + ones), a date range, or explicit `event_ids` from a multi-select. A line it cannot take — no + suggestion to take, already classified or posted, a closed month, a choice the handler refuses — + comes back `skipped` with the reason, never forced. `dry_run: true` reports the counts without + writing; `limit` bounds a call and `truncated` says more matched. + + **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 (AcceptSuggestionsRequest): Take the suggestion on every open bank line the filters + select. + + 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 | OperationEnvelopeAcceptSuggestionsResponse + """ + + 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: AcceptSuggestionsRequest, + idempotency_key: None | str | Unset = UNSET, +) -> Response[ErrorResponse | OperationEnvelopeAcceptSuggestionsResponse]: + """Accept Suggestions + + Take the suggestion on every open bank line the filters select, oldest first: each is classified to + its `suggested_element_id` through the same update a single line goes through, and posted too with + `commit: true`. Select by `source`, `agent_id`, `suggestion_source` (`agent_default` for the learned + ones), a date range, or explicit `event_ids` from a multi-select. A line it cannot take — no + suggestion to take, already classified or posted, a closed month, a choice the handler refuses — + comes back `skipped` with the reason, never forced. `dry_run: true` reports the counts without + writing; `limit` bounds a call and `truncated` says more matched. + + **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 (AcceptSuggestionsRequest): Take the suggestion on every open bank line the filters + select. + + 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 | OperationEnvelopeAcceptSuggestionsResponse] + """ + + 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: AcceptSuggestionsRequest, + idempotency_key: None | str | Unset = UNSET, +) -> ErrorResponse | OperationEnvelopeAcceptSuggestionsResponse | None: + """Accept Suggestions + + Take the suggestion on every open bank line the filters select, oldest first: each is classified to + its `suggested_element_id` through the same update a single line goes through, and posted too with + `commit: true`. Select by `source`, `agent_id`, `suggestion_source` (`agent_default` for the learned + ones), a date range, or explicit `event_ids` from a multi-select. A line it cannot take — no + suggestion to take, already classified or posted, a closed month, a choice the handler refuses — + comes back `skipped` with the reason, never forced. `dry_run: true` reports the counts without + writing; `limit` bounds a call and `truncated` says more matched. + + **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 (AcceptSuggestionsRequest): Take the suggestion on every open bank line the filters + select. + + 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 | OperationEnvelopeAcceptSuggestionsResponse + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/robosystems_client/api/robo_ledger_ledger_events/resolve_reconciling_item.py b/robosystems_client/api/robo_ledger_ledger_events/resolve_reconciling_item.py index 4dd47d1..c16f4d2 100644 --- a/robosystems_client/api/robo_ledger_ledger_events/resolve_reconciling_item.py +++ b/robosystems_client/api/robo_ledger_ledger_events/resolve_reconciling_item.py @@ -121,11 +121,19 @@ def sync_detailed( alignment entry in an open period, local-only so it cannot travel back to the source system and apply the change twice; 'acknowledge' records that the difference was handled elsewhere and clears the flag without touching the ledger (a note is required, and reference_event_id should name the - entry that handled it). An alignment entry authored by hand can name the items it settles in - metadata.resolves_reconciling_items instead, and each is acknowledged against it as it is recorded — - an item left flagged would be caught up a second time. Omit disposition to take the default from - preview-reconciling-item. Clearing the flag means the item stays cleared: the event's payload is set - to the accepted one, so the next sync no longer sees a difference. + entry that handled it: a standing entry on the item's books, other than the item, and not already + counted for something else such as a catch-up or another settlement). An alignment entry authored by + hand can name the items it settles in metadata.resolves_reconciling_items instead, each as + {event_id, expected_drift_detected_at} carrying the preview's stamp, and each is acknowledged + against it as it is recorded — an item left flagged would be caught up a second time. What an + acknowledged entry booked counts toward the item's next change; voiding or reversing that entry, or + a catch-up, flags the item again, and superseding it by a correction that books exactly the item's + difference hands the settlement to the correction. An entry that settles a QuickBooks change has to + stay local, as a catch-up does: create it with metadata.publish_to_source false, booking only the + differences it settles; one that would publish or books more is refused, and so is a later patch + setting it to publish. Omit disposition to take the default from preview-reconciling-item. Clearing + the flag means the item stays cleared: the event's payload is set to the accepted one, so the next + sync no longer sees a difference. **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. @@ -171,11 +179,19 @@ def sync( alignment entry in an open period, local-only so it cannot travel back to the source system and apply the change twice; 'acknowledge' records that the difference was handled elsewhere and clears the flag without touching the ledger (a note is required, and reference_event_id should name the - entry that handled it). An alignment entry authored by hand can name the items it settles in - metadata.resolves_reconciling_items instead, and each is acknowledged against it as it is recorded — - an item left flagged would be caught up a second time. Omit disposition to take the default from - preview-reconciling-item. Clearing the flag means the item stays cleared: the event's payload is set - to the accepted one, so the next sync no longer sees a difference. + entry that handled it: a standing entry on the item's books, other than the item, and not already + counted for something else such as a catch-up or another settlement). An alignment entry authored by + hand can name the items it settles in metadata.resolves_reconciling_items instead, each as + {event_id, expected_drift_detected_at} carrying the preview's stamp, and each is acknowledged + against it as it is recorded — an item left flagged would be caught up a second time. What an + acknowledged entry booked counts toward the item's next change; voiding or reversing that entry, or + a catch-up, flags the item again, and superseding it by a correction that books exactly the item's + difference hands the settlement to the correction. An entry that settles a QuickBooks change has to + stay local, as a catch-up does: create it with metadata.publish_to_source false, booking only the + differences it settles; one that would publish or books more is refused, and so is a later patch + setting it to publish. Omit disposition to take the default from preview-reconciling-item. Clearing + the flag means the item stays cleared: the event's payload is set to the accepted one, so the next + sync no longer sees a difference. **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. @@ -216,11 +232,19 @@ async def asyncio_detailed( alignment entry in an open period, local-only so it cannot travel back to the source system and apply the change twice; 'acknowledge' records that the difference was handled elsewhere and clears the flag without touching the ledger (a note is required, and reference_event_id should name the - entry that handled it). An alignment entry authored by hand can name the items it settles in - metadata.resolves_reconciling_items instead, and each is acknowledged against it as it is recorded — - an item left flagged would be caught up a second time. Omit disposition to take the default from - preview-reconciling-item. Clearing the flag means the item stays cleared: the event's payload is set - to the accepted one, so the next sync no longer sees a difference. + entry that handled it: a standing entry on the item's books, other than the item, and not already + counted for something else such as a catch-up or another settlement). An alignment entry authored by + hand can name the items it settles in metadata.resolves_reconciling_items instead, each as + {event_id, expected_drift_detected_at} carrying the preview's stamp, and each is acknowledged + against it as it is recorded — an item left flagged would be caught up a second time. What an + acknowledged entry booked counts toward the item's next change; voiding or reversing that entry, or + a catch-up, flags the item again, and superseding it by a correction that books exactly the item's + difference hands the settlement to the correction. An entry that settles a QuickBooks change has to + stay local, as a catch-up does: create it with metadata.publish_to_source false, booking only the + differences it settles; one that would publish or books more is refused, and so is a later patch + setting it to publish. Omit disposition to take the default from preview-reconciling-item. Clearing + the flag means the item stays cleared: the event's payload is set to the accepted one, so the next + sync no longer sees a difference. **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. @@ -264,11 +288,19 @@ async def asyncio( alignment entry in an open period, local-only so it cannot travel back to the source system and apply the change twice; 'acknowledge' records that the difference was handled elsewhere and clears the flag without touching the ledger (a note is required, and reference_event_id should name the - entry that handled it). An alignment entry authored by hand can name the items it settles in - metadata.resolves_reconciling_items instead, and each is acknowledged against it as it is recorded — - an item left flagged would be caught up a second time. Omit disposition to take the default from - preview-reconciling-item. Clearing the flag means the item stays cleared: the event's payload is set - to the accepted one, so the next sync no longer sees a difference. + entry that handled it: a standing entry on the item's books, other than the item, and not already + counted for something else such as a catch-up or another settlement). An alignment entry authored by + hand can name the items it settles in metadata.resolves_reconciling_items instead, each as + {event_id, expected_drift_detected_at} carrying the preview's stamp, and each is acknowledged + against it as it is recorded — an item left flagged would be caught up a second time. What an + acknowledged entry booked counts toward the item's next change; voiding or reversing that entry, or + a catch-up, flags the item again, and superseding it by a correction that books exactly the item's + difference hands the settlement to the correction. An entry that settles a QuickBooks change has to + stay local, as a catch-up does: create it with metadata.publish_to_source false, booking only the + differences it settles; one that would publish or books more is refused, and so is a later patch + setting it to publish. Omit disposition to take the default from preview-reconciling-item. Clearing + the flag means the item stays cleared: the event's payload is set to the accepted one, so the next + sync no longer sees a difference. **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. diff --git a/robosystems_client/api/robo_ledger_setup/initialize_ledger.py b/robosystems_client/api/robo_ledger_setup/initialize_ledger.py index 676ddec..e4b1cd1 100644 --- a/robosystems_client/api/robo_ledger_setup/initialize_ledger.py +++ b/robosystems_client/api/robo_ledger_setup/initialize_ledger.py @@ -113,7 +113,9 @@ def sync_detailed( ) -> Response[ErrorResponse | OperationEnvelopeInitializeLedgerResponse]: """Initialize Ledger - One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized. + One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized, + or if draft entries or unposted events are dated before the first open month, where no close would + post them. **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. @@ -166,7 +168,9 @@ def sync( ) -> ErrorResponse | OperationEnvelopeInitializeLedgerResponse | None: """Initialize Ledger - One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized. + One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized, + or if draft entries or unposted events are dated before the first open month, where no close would + post them. **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. @@ -214,7 +218,9 @@ async def asyncio_detailed( ) -> Response[ErrorResponse | OperationEnvelopeInitializeLedgerResponse]: """Initialize Ledger - One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized. + One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized, + or if draft entries or unposted events are dated before the first open month, where no close would + post them. **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. @@ -265,7 +271,9 @@ async def asyncio( ) -> ErrorResponse | OperationEnvelopeInitializeLedgerResponse | None: """Initialize Ledger - One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized. + One-time setup: creates the fiscal calendar and seeds periods. Returns 409 if already initialized, + or if draft entries or unposted events are dated before the first open month, where no close would + post them. **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. diff --git a/robosystems_client/clients/ledger_client.py b/robosystems_client/clients/ledger_client.py index 6151f1e..711882e 100644 --- a/robosystems_client/clients/ledger_client.py +++ b/robosystems_client/clients/ledger_client.py @@ -90,6 +90,9 @@ from ..api.robo_ledger_ledger_events.learn_classification_defaults import ( sync_detailed as op_learn_classification_defaults, ) +from ..api.robo_ledger_ledger_events.accept_suggestions import ( + sync_detailed as op_accept_suggestions, +) from ..api.robo_ledger_ledger_events.update_agent import ( sync_detailed as op_update_agent, ) @@ -438,6 +441,14 @@ MappingCandidates, MappingCandidatesMappingCandidates, ) +from ..graphql.generated.flow_candidates import ( + FlowCandidates, + FlowCandidatesFlowCandidates, +) +from ..graphql.generated.classification_stats import ( + ClassificationStats, + ClassificationStatsClassificationStats, +) from ..graphql.generated.operations import ( GET_INFORMATION_BLOCK_GQL, GET_LEDGER_ACCOUNT_ROLLUPS_GQL, @@ -480,6 +491,8 @@ LIST_LEDGER_TRANSACTIONS_GQL, LIST_LEDGER_UNMAPPED_ELEMENTS_GQL, MAPPING_CANDIDATES_GQL, + FLOW_CANDIDATES_GQL, + CLASSIFICATION_STATS_GQL, ) from ..models.add_publish_list_members_operation import AddPublishListMembersOperation from ..models.auto_map_elements_operation import AutoMapElementsOperation @@ -553,6 +566,8 @@ from ..models.learn_classification_defaults_response import ( LearnClassificationDefaultsResponse, ) +from ..models.accept_suggestions_request import AcceptSuggestionsRequest +from ..models.accept_suggestions_response import AcceptSuggestionsResponse from ..models.set_reconciliation_policy_request import SetReconciliationPolicyRequest from ..models.set_reconciliation_policy_request_statement_cycle_type_0 import ( SetReconciliationPolicyRequestStatementCycleType0, @@ -1420,6 +1435,21 @@ def get_mapping_candidates( ) return MappingCandidates.model_validate(data).mapping_candidates + def get_flow_candidates( + self, graph_id: str, *, entity_id: str | None = None + ) -> list[FlowCandidatesFlowCandidates]: + """The cash flow rows a line may be tagged with: the leaf investing and + financing rows of the entity's cash flow statement under its Reporting + Style (``entity_id``, default the group parent), by section then name. + A flow is a second classification beside the account — it overrides the + account's default flow on the cash flow and equity statements — and this + is the set the write path accepts, so a picker built from it never + offers a row the statement cannot present. The ``qname`` is what a bank + line's ``classified_flow_qname`` takes. + """ + data = self._query(graph_id, FLOW_CANDIDATES_GQL, {"entityId": entity_id}) + return FlowCandidates.model_validate(data).flow_candidates + def create_mapping_association( self, graph_id: str, @@ -2175,6 +2205,41 @@ def learn_classification_defaults( "Learn classification defaults", envelope, LearnClassificationDefaultsResponse ) + def accept_suggestions( + self, graph_id: str, body: dict[str, Any] | None = None + ) -> AcceptSuggestionsResponse: + """Take the suggestion on every open bank line the filters select, oldest + first, through the same update a single line goes through; ``commit`` + posts them too. ``body`` takes the filters (``source``, ``agent_id``, + ``suggestion_source`` — ``agent_default`` for the learned ones — a date + range as ``occurred_from`` / ``occurred_to``, or explicit ``event_ids``), + ``entity_id``, ``commit``, ``dry_run`` and ``limit``. A line that cannot + be taken comes back ``skipped`` with its reason, never forced; the + result carries the counts, ``truncated``, and every line looked at. + """ + request = AcceptSuggestionsRequest.from_dict(body or {}) + response = op_accept_suggestions( + graph_id=graph_id, body=request, client=self._get_client() + ) + envelope = self._call_op("Accept suggestions", response) + return self._typed_result("Accept suggestions", envelope, AcceptSuggestionsResponse) + + def get_classification_stats( + self, graph_id: str, period: str, *, entity_id: str | None = None + ) -> ClassificationStatsClassificationStats | None: + """How one month's bank lines were classified, per suggestion source: + taken as suggested, overridden, split, or chosen with no suggestion to + take, and the share that needed a person's choice. ``period`` is + ``YYYY-MM`` over the lines' own dates; ``entity_id`` defaults to the + group parent. Lines posted before outcomes were recorded are + ``unstamped``, outside the share. ``None`` when the ledger is not + initialized. + """ + data = self._query( + graph_id, CLASSIFICATION_STATS_GQL, {"period": period, "entityId": entity_id} + ) + return ClassificationStats.model_validate(data).classification_stats + # ── 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 955b892..b39dad3 100644 --- a/robosystems_client/graphql/generated/__init__.py +++ b/robosystems_client/graphql/generated/__init__.py @@ -1,5 +1,10 @@ from .base_client import BaseClient from .base_model import BaseModel, Upload +from .classification_stats import ( + ClassificationStats, + ClassificationStatsClassificationStats, + ClassificationStatsClassificationStatsBySource, +) from .client import Client from .enums import ReportDownloadFormat, ReportLifecycle from .exceptions import ( @@ -9,6 +14,7 @@ GraphQLClientHttpError, GraphQLClientInvalidResponseError, ) +from .flow_candidates import FlowCandidates, FlowCandidatesFlowCandidates from .get_information_block import ( GetInformationBlock, GetInformationBlockInformationBlock, @@ -340,6 +346,8 @@ ) from .mapping_candidates import MappingCandidates, MappingCandidatesMappingCandidates from .operations import ( + CLASSIFICATION_STATS_GQL, + FLOW_CANDIDATES_GQL, GET_INFORMATION_BLOCK_GQL, GET_INVESTOR_HOLDINGS_GQL, GET_INVESTOR_PORTFOLIO_BLOCK_GQL, @@ -409,7 +417,14 @@ __all__ = [ "BaseClient", "BaseModel", + "CLASSIFICATION_STATS_GQL", + "ClassificationStats", + "ClassificationStatsClassificationStats", + "ClassificationStatsClassificationStatsBySource", "Client", + "FLOW_CANDIDATES_GQL", + "FlowCandidates", + "FlowCandidatesFlowCandidates", "GET_INFORMATION_BLOCK_GQL", "GET_INVESTOR_HOLDINGS_GQL", "GET_INVESTOR_PORTFOLIO_BLOCK_GQL", diff --git a/robosystems_client/graphql/generated/classification_stats.py b/robosystems_client/graphql/generated/classification_stats.py new file mode 100644 index 0000000..6902836 --- /dev/null +++ b/robosystems_client/graphql/generated/classification_stats.py @@ -0,0 +1,40 @@ +from typing import Optional + +from pydantic import Field + +from .base_model import BaseModel + + +class ClassificationStats(BaseModel): + classification_stats: Optional["ClassificationStatsClassificationStats"] = Field( + alias="classificationStats" + ) + + +class ClassificationStatsClassificationStats(BaseModel): + period: str + entity_id: Optional[str] = Field(alias="entityId") + lines: int + accepted: int + overridden: int + unsuggested: int + split: int + unstamped: int + human_choice_share: Optional[float] = Field(alias="humanChoiceShare") + open_lines: int = Field(alias="openLines") + by_source: list["ClassificationStatsClassificationStatsBySource"] = Field( + alias="bySource" + ) + + +class ClassificationStatsClassificationStatsBySource(BaseModel): + source: str + lines: int + accepted: int + overridden: int + unsuggested: int + split: int + + +ClassificationStats.model_rebuild() +ClassificationStatsClassificationStats.model_rebuild() diff --git a/robosystems_client/graphql/generated/client.py b/robosystems_client/graphql/generated/client.py index af4085b..98de538 100644 --- a/robosystems_client/graphql/generated/client.py +++ b/robosystems_client/graphql/generated/client.py @@ -2,7 +2,9 @@ from .base_client import BaseClient from .base_model import UNSET, UnsetType +from .classification_stats import ClassificationStats from .enums import ReportDownloadFormat, ReportLifecycle +from .flow_candidates import FlowCandidates from .get_information_block import GetInformationBlock from .get_investor_holdings import GetInvestorHoldings from .get_investor_portfolio_block import GetInvestorPortfolioBlock @@ -61,6 +63,8 @@ from .list_library_taxonomy_arcs import ListLibraryTaxonomyArcs from .mapping_candidates import MappingCandidates from .operations import ( + CLASSIFICATION_STATS_GQL, + FLOW_CANDIDATES_GQL, GET_INFORMATION_BLOCK_GQL, GET_INVESTOR_HOLDINGS_GQL, GET_INVESTOR_PORTFOLIO_BLOCK_GQL, @@ -243,6 +247,32 @@ def list_investor_securities( data = self.get_data(response) return ListInvestorSecurities.model_validate(data) + def classification_stats( + self, period: str, entity_id: Union[Optional[str], UnsetType] = UNSET, **kwargs: Any + ) -> ClassificationStats: + variables: dict[str, object] = {"period": period, "entityId": entity_id} + response = self.execute( + query=CLASSIFICATION_STATS_GQL, + operation_name="ClassificationStats", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return ClassificationStats.model_validate(data) + + def flow_candidates( + self, entity_id: Union[Optional[str], UnsetType] = UNSET, **kwargs: Any + ) -> FlowCandidates: + variables: dict[str, object] = {"entityId": entity_id} + response = self.execute( + query=FLOW_CANDIDATES_GQL, + operation_name="FlowCandidates", + variables=variables, + **kwargs, + ) + data = self.get_data(response) + return FlowCandidates.model_validate(data) + def get_information_block( self, id: str, diff --git a/robosystems_client/graphql/generated/flow_candidates.py b/robosystems_client/graphql/generated/flow_candidates.py new file mode 100644 index 0000000..b4288f1 --- /dev/null +++ b/robosystems_client/graphql/generated/flow_candidates.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from .base_model import BaseModel + + +class FlowCandidates(BaseModel): + flow_candidates: list["FlowCandidatesFlowCandidates"] = Field(alias="flowCandidates") + + +class FlowCandidatesFlowCandidates(BaseModel): + element_id: str = Field(alias="elementId") + qname: str + name: str + activity: str + + +FlowCandidates.model_rebuild() diff --git a/robosystems_client/graphql/generated/operations.py b/robosystems_client/graphql/generated/operations.py index 280d6af..ddf0b13 100644 --- a/robosystems_client/graphql/generated/operations.py +++ b/robosystems_client/graphql/generated/operations.py @@ -1,4 +1,6 @@ __all__ = [ + "CLASSIFICATION_STATS_GQL", + "FLOW_CANDIDATES_GQL", "GET_INFORMATION_BLOCK_GQL", "GET_INVESTOR_HOLDINGS_GQL", "GET_INVESTOR_PORTFOLIO_BLOCK_GQL", @@ -278,6 +280,42 @@ } """ +CLASSIFICATION_STATS_GQL = """ +query ClassificationStats($period: String!, $entityId: String) { + classificationStats(period: $period, entityId: $entityId) { + period + entityId + lines + accepted + overridden + unsuggested + split + unstamped + humanChoiceShare + openLines + bySource { + source + lines + accepted + overridden + unsuggested + split + } + } +} +""" + +FLOW_CANDIDATES_GQL = """ +query FlowCandidates($entityId: String) { + flowCandidates(entityId: $entityId) { + elementId + qname + name + activity + } +} +""" + GET_INFORMATION_BLOCK_GQL = """ query GetInformationBlock($id: ID!, $scenarioId: String, $series: Boolean! = false, $seriesHistory: Int, $seriesForecast: Int, $entityId: String) { informationBlock( diff --git a/robosystems_client/graphql/operations/ledger/ClassificationStats.graphql b/robosystems_client/graphql/operations/ledger/ClassificationStats.graphql new file mode 100644 index 0000000..20e8067 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/ClassificationStats.graphql @@ -0,0 +1,22 @@ +query ClassificationStats($period: String!, $entityId: String) { + classificationStats(period: $period, entityId: $entityId) { + period + entityId + lines + accepted + overridden + unsuggested + split + unstamped + humanChoiceShare + openLines + bySource { + source + lines + accepted + overridden + unsuggested + split + } + } +} diff --git a/robosystems_client/graphql/operations/ledger/FlowCandidates.graphql b/robosystems_client/graphql/operations/ledger/FlowCandidates.graphql new file mode 100644 index 0000000..df5c0c5 --- /dev/null +++ b/robosystems_client/graphql/operations/ledger/FlowCandidates.graphql @@ -0,0 +1,8 @@ +query FlowCandidates($entityId: String) { + flowCandidates(entityId: $entityId) { + elementId + qname + name + activity + } +} diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index 231acb9..75d32da 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -460,6 +460,25 @@ type Query { entityId: String = null ): [Element!]! + """ + The cash flow rows a line may be tagged with. + + A flow is a second classification beside the account: it overrides the + account's default flow on the cash flow and equity statements (a bank + line's `classified_flow_qname`, a journal-entry line's + `transaction_description_code`). These are the leaf investing and + financing rows of the entity's cash flow statement under its Reporting + Style - the set the write path accepts - so a picker built from them + never offers a row the statement cannot present. By section, investing + then financing, and by name within each. + """ + flowCandidates( + """ + The entity whose statement the rows come from. Omit for the group parent. + """ + entityId: String = null + ): [FlowCandidate!]! + """CoA elements not yet mapped to the reporting taxonomy.""" unmappedElements( """The mapping to measure against. Omit for the active mapping.""" @@ -490,6 +509,21 @@ type Query { mappingId: String! ): MappingDetail + """ + How one month's bank lines were classified, per suggestion source: + taken as suggested, overridden, split, or chosen with no suggestion to + take, and the share that needed a person's choice. Counted from the + lines committed in the month; lines posted before outcomes were recorded + are `unstamped` and outside the share. + """ + classificationStats( + """The month, `YYYY-MM`, over the lines' own dates.""" + period: String! + + """The entity whose lines to count. Omit for the group parent.""" + entityId: String = null + ): ClassificationStats + """Coverage stats for a mapping.""" mappingCoverage( """The mapping structure to report coverage for.""" @@ -2348,6 +2382,28 @@ type Element { externalSource: String } +""" +A cash flow row a line may be tagged with: the flow that overrides the +account's default on the cash flow and equity statements. +""" +type FlowCandidate { + """The flow concept's element id.""" + elementId: String! + + """ + The flow's qname, the value a bank line's `classified_flow_qname` and a journal-entry line's `transaction_description_code` take. + """ + qname: String! + + """The flow's name, as the statement labels its row.""" + name: String! + + """ + `investing` or `financing`: the section of the statement the row sits in. + """ + activity: String! +} + """An element not yet mapped to the reporting taxonomy.""" type UnmappedElement { id: String! @@ -2437,6 +2493,67 @@ type Association { approvedBy: String } +"""How a month's bank lines were classified, counted from the lines.""" +type ClassificationStats { + """The month, `YYYY-MM`.""" + period: String! + + """The entity counted.""" + entityId: String + + """Committed bank lines in the month that recorded an outcome.""" + lines: Int! + + """Lines posted to the account suggested.""" + accepted: Int! + + """Lines posted to another account than the one suggested.""" + overridden: Int! + + """Lines that had no suggestion, so a person chose.""" + unsuggested: Int! + + """Lines split across accounts.""" + split: Int! + + """Committed lines from before outcomes were recorded; outside the share.""" + unstamped: Int! + + """ + The share of lines that needed a person's choice: everything but `accepted`, over `lines`. Null when no line recorded an outcome. + """ + humanChoiceShare: Float + + """Lines in the month still captured or classified, not posted.""" + openLines: Int! + + """The same counts per suggestion source, largest first.""" + bySource: [ClassificationSourceStats!]! +} + +"""One suggestion source's share of a month's committed bank lines.""" +type ClassificationSourceStats { + """ + Where the suggestion came from: `agent_default`, `tier0`, `ai`, or `none` for lines that carried no suggestion. + """ + source: String! + + """Committed lines whose suggestion came from here.""" + lines: Int! + + """Lines posted to the account suggested.""" + accepted: Int! + + """Lines posted to another account than the one suggested.""" + overridden: Int! + + """Lines that had no suggestion, so a person chose.""" + unsuggested: Int! + + """Lines split across accounts.""" + split: Int! +} + """Coverage stats for a mapping.""" type MappingCoverage { mappingId: String! diff --git a/robosystems_client/models/__init__.py b/robosystems_client/models/__init__.py index 5aa610b..3f47247 100644 --- a/robosystems_client/models/__init__.py +++ b/robosystems_client/models/__init__.py @@ -1,5 +1,9 @@ """Contains all the data models used in inputs/outputs""" +from .accept_suggestion_line import AcceptSuggestionLine +from .accept_suggestion_line_outcome import AcceptSuggestionLineOutcome +from .accept_suggestions_request import AcceptSuggestionsRequest +from .accept_suggestions_response import AcceptSuggestionsResponse from .account_info import AccountInfo from .add_graph_member_request import AddGraphMemberRequest from .add_publish_list_members_operation import AddPublishListMembersOperation @@ -449,6 +453,12 @@ from .operation_costs_ai_operations import OperationCostsAiOperations from .operation_costs_token_pricing import OperationCostsTokenPricing from .operation_envelope import OperationEnvelope +from .operation_envelope_accept_suggestions_response import ( + OperationEnvelopeAcceptSuggestionsResponse, +) +from .operation_envelope_accept_suggestions_response_status import ( + OperationEnvelopeAcceptSuggestionsResponseStatus, +) from .operation_envelope_assert_metrics_response import ( OperationEnvelopeAssertMetricsResponse, ) @@ -1149,6 +1159,10 @@ from .view_response_summary_type_0 import ViewResponseSummaryType0 __all__ = ( + "AcceptSuggestionLine", + "AcceptSuggestionLineOutcome", + "AcceptSuggestionsRequest", + "AcceptSuggestionsResponse", "AccountInfo", "AddGraphMemberRequest", "AddPublishListMembersOperation", @@ -1512,6 +1526,8 @@ "OperationCostsAiOperations", "OperationCostsTokenPricing", "OperationEnvelope", + "OperationEnvelopeAcceptSuggestionsResponse", + "OperationEnvelopeAcceptSuggestionsResponseStatus", "OperationEnvelopeAssertMetricsResponse", "OperationEnvelopeAssertMetricsResponseStatus", "OperationEnvelopeAssociationResponse", diff --git a/robosystems_client/models/accept_suggestion_line.py b/robosystems_client/models/accept_suggestion_line.py new file mode 100644 index 0000000..797825e --- /dev/null +++ b/robosystems_client/models/accept_suggestion_line.py @@ -0,0 +1,156 @@ +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 ..models.accept_suggestion_line_outcome import AcceptSuggestionLineOutcome +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AcceptSuggestionLine") + + +@_attrs_define +class AcceptSuggestionLine: + """What the batch did with one line. + + Attributes: + event_id (str): The line. + outcome (AcceptSuggestionLineOutcome): What happened to it. + reason (None | str | Unset): Why a skipped line was not taken: `not_open` (already classified, posted or + retracted), `no_suggestion` (nothing to take, or a name that matches no account), `source_changed` (the feed + changed it after capture), `closed_period`, `locked` (another process holds it; retry), or `refused` (the + handler would not post the choice). + detail (None | str | Unset): The refusal's own words, when there was one. + element_id (None | str | Unset): The account the suggestion names. + description (None | str | Unset): The line's description. + """ + + event_id: str + outcome: AcceptSuggestionLineOutcome + reason: None | str | Unset = UNSET + detail: None | str | Unset = UNSET + element_id: None | str | Unset = UNSET + description: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + event_id = self.event_id + + outcome = self.outcome.value + + reason: None | str | Unset + if isinstance(self.reason, Unset): + reason = UNSET + else: + reason = self.reason + + detail: None | str | Unset + if isinstance(self.detail, Unset): + detail = UNSET + else: + detail = self.detail + + element_id: None | str | Unset + if isinstance(self.element_id, Unset): + element_id = UNSET + else: + element_id = self.element_id + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "event_id": event_id, + "outcome": outcome, + } + ) + if reason is not UNSET: + field_dict["reason"] = reason + if detail is not UNSET: + field_dict["detail"] = detail + if element_id is not UNSET: + field_dict["element_id"] = element_id + if description is not UNSET: + field_dict["description"] = description + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + event_id = d.pop("event_id") + + outcome = AcceptSuggestionLineOutcome(d.pop("outcome")) + + def _parse_reason(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + reason = _parse_reason(d.pop("reason", UNSET)) + + def _parse_detail(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + detail = _parse_detail(d.pop("detail", UNSET)) + + 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_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + accept_suggestion_line = cls( + event_id=event_id, + outcome=outcome, + reason=reason, + detail=detail, + element_id=element_id, + description=description, + ) + + accept_suggestion_line.additional_properties = d + return accept_suggestion_line + + @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/accept_suggestion_line_outcome.py b/robosystems_client/models/accept_suggestion_line_outcome.py new file mode 100644 index 0000000..88e0c47 --- /dev/null +++ b/robosystems_client/models/accept_suggestion_line_outcome.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class AcceptSuggestionLineOutcome(str, Enum): + CLASSIFIED = "classified" + COMMITTED = "committed" + SKIPPED = "skipped" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/accept_suggestions_request.py b/robosystems_client/models/accept_suggestions_request.py new file mode 100644 index 0000000..3dbd5e1 --- /dev/null +++ b/robosystems_client/models/accept_suggestions_request.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import datetime +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="AcceptSuggestionsRequest") + + +@_attrs_define +class AcceptSuggestionsRequest: + """Take the suggestion on every open bank line the filters select. + + Attributes: + entity_id (None | str | Unset): The entity whose inbox to act on. Omit for the group parent. + event_ids (list[str] | None | Unset): Explicit lines, as from the inbox's multi-select; each is answered, even + one that cannot be taken. The other filters still narrow them. + source (None | str | Unset): Only lines from this feed (`plaid`, `mercury`). + agent_id (None | str | Unset): Only one counterparty's lines. + suggestion_source (None | str | Unset): Only lines whose suggestion came from here: `agent_default` (the + counterparty's learned default), `tier0` (the bank's category) or `ai`. + occurred_from (datetime.date | None | Unset): Only lines dated on or after this day. + occurred_to (datetime.date | None | Unset): Only lines dated on or before this day. + commit (bool | Unset): Post each taken line as well (captured → committed). Off, the lines are classified and + wait for a person to post them. Default: False. + limit (int | Unset): At most this many lines per call, oldest first; `truncated` in the response says whether + more matched. Default: 200. + dry_run (bool | Unset): Report what each line would do without writing. A closed month is found only when a line + actually posts, so a real run can still skip one a dry run counted. Default: False. + """ + + entity_id: None | str | Unset = UNSET + event_ids: list[str] | None | Unset = UNSET + source: None | str | Unset = UNSET + agent_id: None | str | Unset = UNSET + suggestion_source: None | str | Unset = UNSET + occurred_from: datetime.date | None | Unset = UNSET + occurred_to: datetime.date | None | Unset = UNSET + commit: bool | Unset = False + limit: int | Unset = 200 + dry_run: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entity_id: None | str | Unset + if isinstance(self.entity_id, Unset): + entity_id = UNSET + else: + entity_id = self.entity_id + + event_ids: list[str] | None | Unset + if isinstance(self.event_ids, Unset): + event_ids = UNSET + elif isinstance(self.event_ids, list): + event_ids = self.event_ids + + else: + event_ids = self.event_ids + + source: None | str | Unset + if isinstance(self.source, Unset): + source = UNSET + else: + source = self.source + + agent_id: None | str | Unset + if isinstance(self.agent_id, Unset): + agent_id = UNSET + else: + agent_id = self.agent_id + + suggestion_source: None | str | Unset + if isinstance(self.suggestion_source, Unset): + suggestion_source = UNSET + else: + suggestion_source = self.suggestion_source + + occurred_from: None | str | Unset + if isinstance(self.occurred_from, Unset): + occurred_from = UNSET + elif isinstance(self.occurred_from, datetime.date): + occurred_from = self.occurred_from.isoformat() + else: + occurred_from = self.occurred_from + + occurred_to: None | str | Unset + if isinstance(self.occurred_to, Unset): + occurred_to = UNSET + elif isinstance(self.occurred_to, datetime.date): + occurred_to = self.occurred_to.isoformat() + else: + occurred_to = self.occurred_to + + commit = self.commit + + limit = self.limit + + dry_run = self.dry_run + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if entity_id is not UNSET: + field_dict["entity_id"] = entity_id + if event_ids is not UNSET: + field_dict["event_ids"] = event_ids + if source is not UNSET: + field_dict["source"] = source + if agent_id is not UNSET: + field_dict["agent_id"] = agent_id + if suggestion_source is not UNSET: + field_dict["suggestion_source"] = suggestion_source + if occurred_from is not UNSET: + field_dict["occurred_from"] = occurred_from + if occurred_to is not UNSET: + field_dict["occurred_to"] = occurred_to + if commit is not UNSET: + field_dict["commit"] = commit + if limit is not UNSET: + field_dict["limit"] = limit + if dry_run is not UNSET: + field_dict["dry_run"] = dry_run + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_entity_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + entity_id = _parse_entity_id(d.pop("entity_id", UNSET)) + + def _parse_event_ids(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + event_ids_type_0 = cast(list[str], data) + + return event_ids_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + event_ids = _parse_event_ids(d.pop("event_ids", UNSET)) + + def _parse_source(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + source = _parse_source(d.pop("source", UNSET)) + + def _parse_agent_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + agent_id = _parse_agent_id(d.pop("agent_id", UNSET)) + + def _parse_suggestion_source(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + suggestion_source = _parse_suggestion_source(d.pop("suggestion_source", UNSET)) + + def _parse_occurred_from(data: object) -> datetime.date | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + occurred_from_type_0 = datetime.date.fromisoformat(data) + + return occurred_from_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.date | None | Unset, data) + + occurred_from = _parse_occurred_from(d.pop("occurred_from", UNSET)) + + def _parse_occurred_to(data: object) -> datetime.date | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + occurred_to_type_0 = datetime.date.fromisoformat(data) + + return occurred_to_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.date | None | Unset, data) + + occurred_to = _parse_occurred_to(d.pop("occurred_to", UNSET)) + + commit = d.pop("commit", UNSET) + + limit = d.pop("limit", UNSET) + + dry_run = d.pop("dry_run", UNSET) + + accept_suggestions_request = cls( + entity_id=entity_id, + event_ids=event_ids, + source=source, + agent_id=agent_id, + suggestion_source=suggestion_source, + occurred_from=occurred_from, + occurred_to=occurred_to, + commit=commit, + limit=limit, + dry_run=dry_run, + ) + + accept_suggestions_request.additional_properties = d + return accept_suggestions_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/accept_suggestions_response.py b/robosystems_client/models/accept_suggestions_response.py new file mode 100644 index 0000000..52a449d --- /dev/null +++ b/robosystems_client/models/accept_suggestions_response.py @@ -0,0 +1,124 @@ +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 + +if TYPE_CHECKING: + from ..models.accept_suggestion_line import AcceptSuggestionLine + + +T = TypeVar("T", bound="AcceptSuggestionsResponse") + + +@_attrs_define +class AcceptSuggestionsResponse: + """The batch's counts, and every line it looked at. + + Attributes: + selected (int): Lines the filters matched, before the limit. + classified (int): Lines classified to their suggestion and left to post. + committed (int): Lines classified to their suggestion and posted. + skipped (int): Lines not taken, each with a reason. + truncated (bool): More lines matched than the limit allowed in this call. + dry_run (bool): Nothing was written. + lines (list[AcceptSuggestionLine]): Each line looked at, oldest first. + """ + + selected: int + classified: int + committed: int + skipped: int + truncated: bool + dry_run: bool + lines: list[AcceptSuggestionLine] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + selected = self.selected + + classified = self.classified + + committed = self.committed + + skipped = self.skipped + + truncated = self.truncated + + dry_run = self.dry_run + + lines = [] + for lines_item_data in self.lines: + lines_item = lines_item_data.to_dict() + lines.append(lines_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "selected": selected, + "classified": classified, + "committed": committed, + "skipped": skipped, + "truncated": truncated, + "dry_run": dry_run, + "lines": lines, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.accept_suggestion_line import AcceptSuggestionLine + + d = dict(src_dict) + selected = d.pop("selected") + + classified = d.pop("classified") + + committed = d.pop("committed") + + skipped = d.pop("skipped") + + truncated = d.pop("truncated") + + dry_run = d.pop("dry_run") + + lines = [] + _lines = d.pop("lines") + for lines_item_data in _lines: + lines_item = AcceptSuggestionLine.from_dict(lines_item_data) + + lines.append(lines_item) + + accept_suggestions_response = cls( + selected=selected, + classified=classified, + committed=committed, + skipped=skipped, + truncated=truncated, + dry_run=dry_run, + lines=lines, + ) + + accept_suggestions_response.additional_properties = d + return accept_suggestions_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/create_schedule_request.py b/robosystems_client/models/create_schedule_request.py index e2904b8..59742c6 100644 --- a/robosystems_client/models/create_schedule_request.py +++ b/robosystems_client/models/create_schedule_request.py @@ -35,10 +35,10 @@ class CreateScheduleRequest: schedule_metadata (None | ScheduleMetadataRequest | Unset): closed_through (datetime.date | None | Unset): Watermark for onboarding. Facts with period_end ≤ this date are flagged 'historical' and their schedule_entry_due obligations are emitted 'voided', so the close workflow starts - drafting at the first open period. Set this to the last day of the fiscal calendar's closed_through month - (calendar '2026-05' → '2026-05-31') whether those months were actually closed in RoboLedger or just baseline- - watermarked at initialization. Omitting it (when prior periods exist) leaves pre-watermark periods as 'pending' - obligations that block the first close. + drafting at the first open period. Defaults to the calendar's history end: the last day of its closed_through + month (calendar '2026-05' → '2026-05-31'), or the day before its first month when nothing has closed yet. An + earlier date is raised to it, since an obligation there could never post; set a later one only to treat more + months as already booked. source_transaction_id (None | str | Unset): Free-form reference to the originating GL transaction (e.g. an import ID, ledger entry ID, or external system key). Stored in artifact_mechanics for audit; no FK constraint. """ diff --git a/robosystems_client/models/operation_envelope_accept_suggestions_response.py b/robosystems_client/models/operation_envelope_accept_suggestions_response.py new file mode 100644 index 0000000..cb1f2f1 --- /dev/null +++ b/robosystems_client/models/operation_envelope_accept_suggestions_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_accept_suggestions_response_status import ( + OperationEnvelopeAcceptSuggestionsResponseStatus, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.accept_suggestions_response import AcceptSuggestionsResponse + + +T = TypeVar("T", bound="OperationEnvelopeAcceptSuggestionsResponse") + + +@_attrs_define +class OperationEnvelopeAcceptSuggestionsResponse: + """ + Attributes: + operation (str): Kebab-case operation name + operation_id (str): op_-prefixed ULID for audit and SSE correlation + status (OperationEnvelopeAcceptSuggestionsResponseStatus): Operation lifecycle state + at (str): ISO-8601 UTC timestamp + result (AcceptSuggestionsResponse | 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: OperationEnvelopeAcceptSuggestionsResponseStatus + at: str + result: AcceptSuggestionsResponse | 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.accept_suggestions_response import AcceptSuggestionsResponse + + 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, AcceptSuggestionsResponse): + 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.accept_suggestions_response import AcceptSuggestionsResponse + + d = dict(src_dict) + operation = d.pop("operation") + + operation_id = d.pop("operationId") + + status = OperationEnvelopeAcceptSuggestionsResponseStatus(d.pop("status")) + + at = d.pop("at") + + def _parse_result(data: object) -> AcceptSuggestionsResponse | 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 = AcceptSuggestionsResponse.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(AcceptSuggestionsResponse | 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_accept_suggestions_response = cls( + operation=operation, + operation_id=operation_id, + status=status, + at=at, + result=result, + created_by=created_by, + idempotent_replay=idempotent_replay, + ) + + operation_envelope_accept_suggestions_response.additional_properties = d + return operation_envelope_accept_suggestions_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_accept_suggestions_response_status.py b/robosystems_client/models/operation_envelope_accept_suggestions_response_status.py new file mode 100644 index 0000000..9256963 --- /dev/null +++ b/robosystems_client/models/operation_envelope_accept_suggestions_response_status.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OperationEnvelopeAcceptSuggestionsResponseStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + + def __str__(self) -> str: + return str(self.value) diff --git a/robosystems_client/models/rebuild_schedule_request.py b/robosystems_client/models/rebuild_schedule_request.py index bea5766..f2087d8 100644 --- a/robosystems_client/models/rebuild_schedule_request.py +++ b/robosystems_client/models/rebuild_schedule_request.py @@ -21,8 +21,9 @@ class RebuildScheduleRequest: monthly_amount / period bounds on the Structure's metadata). The historical-vs-in-scope split is re-derived from the CURRENT fiscal - calendar `closed_through`, so a rebuild re-scopes the schedule to - today's close state. Use this to pick up a fixed generator (e.g. the + calendar (months through its `closed_through`, or before its first + month, are history), so a rebuild re-scopes the schedule to today's + close state. Use this to pick up a fixed generator (e.g. the roll-forward direction fix) without orphaning obligations. Attributes: diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index 0a343fe..9767483 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -2244,6 +2244,74 @@ def test_learn_classification_defaults(self, mock_op, mock_config, graph_id): assert result["agents_learned"] == 12 assert mock_op.call_args.kwargs["body"].to_dict() == {"dry_run": True} + @patch("robosystems_client.clients.ledger_client.op_accept_suggestions") + def test_accept_suggestions(self, mock_op, mock_config, graph_id): + mock_op.return_value = _mock_response( + _envelope( + "accept-suggestions", + { + "selected": 3, + "classified": 0, + "committed": 2, + "skipped": 1, + "truncated": False, + "dry_run": False, + "lines": [ + { + "event_id": "evt_1", + "outcome": "committed", + "reason": None, + "detail": None, + "element_id": "elem_software", + "description": "GUSTO 10-01", + }, + { + "event_id": "evt_2", + "outcome": "skipped", + "reason": "closed_period", + "detail": "Period 2026-08 is closed.", + "element_id": "elem_software", + "description": "GUSTO 08-12", + }, + ], + }, + ) + ) + client = LedgerClient(mock_config) + result = client.accept_suggestions( + graph_id, + {"entity_id": "ent_sub", "suggestion_source": "agent_default", "commit": True}, + ) + assert result["committed"] == 2 + assert result["lines"][1]["reason"] == "closed_period" + sent = mock_op.call_args.kwargs["body"].to_dict() + assert sent["entity_id"] == "ent_sub" + assert sent["suggestion_source"] == "agent_default" + assert sent["commit"] is True + + @patch("robosystems_client.clients.ledger_client.op_accept_suggestions") + def test_accept_suggestions_with_nothing_named(self, mock_op, mock_config, graph_id): + """No filters: every open suggested line of the group parent, classified + only — the server's own defaults.""" + mock_op.return_value = _mock_response( + _envelope( + "accept-suggestions", + { + "selected": 0, + "classified": 0, + "committed": 0, + "skipped": 0, + "truncated": False, + "dry_run": False, + "lines": [], + }, + ) + ) + client = LedgerClient(mock_config) + client.accept_suggestions(graph_id) + # An empty body: every default is the server's. + assert mock_op.call_args.kwargs["body"].to_dict() == {} + # ── Event handlers ──────────────────────────────────────────────────────── @@ -2664,6 +2732,92 @@ def test_mapping_candidates_forward_the_entity( } +@pytest.mark.unit +class TestFlowCandidates: + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_get_flow_candidates(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = { + "flowCandidates": [ + { + "elementId": "el_capex", + "qname": "rs-gaap:PaymentsToAcquirePropertyPlantAndEquipment", + "name": "Payments to Acquire Property, Plant, and Equipment", + "activity": "investing", + }, + { + "elementId": "el_std", + "qname": "rs-gaap:ProceedsFromShortTermDebt", + "name": "Proceeds from Short-Term Debt", + "activity": "financing", + }, + ] + } + client = LedgerClient(mock_config) + rows = client.get_flow_candidates(graph_id) + assert [r.qname for r in rows] == [ + "rs-gaap:PaymentsToAcquirePropertyPlantAndEquipment", + "rs-gaap:ProceedsFromShortTermDebt", + ] + assert rows[1].activity == "financing" + assert mock_execute.call_args[0][0] == graph_id + # No entity named: the read defaults to the group parent. + assert mock_execute.call_args[0][2] in (None, {}) + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_flow_candidates_forward_the_entity( + self, mock_execute, mock_config, graph_id + ): + mock_execute.return_value = {"flowCandidates": []} + client = LedgerClient(mock_config) + client.get_flow_candidates(graph_id, entity_id="ent_sub") + assert mock_execute.call_args[0][2] == {"entityId": "ent_sub"} + + +@pytest.mark.unit +class TestClassificationStats: + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_get_classification_stats(self, mock_execute, mock_config, graph_id): + mock_execute.return_value = { + "classificationStats": { + "period": "2026-10", + "entityId": "ent_sub", + "lines": 5, + "accepted": 2, + "overridden": 1, + "unsuggested": 1, + "split": 1, + "unstamped": 1, + "humanChoiceShare": 0.6, + "openLines": 2, + "bySource": [ + { + "source": "agent_default", + "lines": 3, + "accepted": 2, + "overridden": 1, + "unsuggested": 0, + "split": 0, + } + ], + } + } + client = LedgerClient(mock_config) + stats = client.get_classification_stats(graph_id, "2026-10", entity_id="ent_sub") + assert stats is not None + assert (stats.lines, stats.accepted, stats.human_choice_share) == (5, 2, 0.6) + assert stats.by_source[0].source == "agent_default" + assert mock_execute.call_args[0][2] == {"period": "2026-10", "entityId": "ent_sub"} + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_classification_stats_default_to_the_parent_and_may_be_null( + self, mock_execute, mock_config, graph_id + ): + mock_execute.return_value = {"classificationStats": None} + client = LedgerClient(mock_config) + assert client.get_classification_stats(graph_id, "2026-10") is None + assert mock_execute.call_args[0][2] == {"period": "2026-10"} + + # ── Compute metrics ────────────────────────────────────────────────────