From 4abfd8aa3d1f8c7024ba8a89aa5ba61d1a67886a Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Fri, 7 Aug 2026 02:02:47 -0500 Subject: [PATCH] feat(ledger): reconciling-item rename, obligation detail on the calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated against main with #1072 in it. The 1.3.0 release missed that rename: the SDK generates from whatever the local stack serves, and that stack was running a branch cut before #1072 merged — so 1.3.0 shipped `payload_drift` twenty-seven minutes after `is_reconciling_item` landed on main. This corrects it, on `EventBlockEnvelope` and in the schema snapshot. The rename touches generated internals only; no facade signature exposed the field, so nothing consumer-reachable changes — additive minor. `GetLedgerFiscalCalendar` now selects the obligation detail the API has carried since #1071. `blockers` said a close was held; it couldn't say by what. The query adds `pendingObligationCount` / `strandedObligationCount`, their up-to-5 samples (schedule id + name + period), `earliestPendingPeriod` and `syncStaleDays`, so a caller can name the schedules holding the close instead of reporting that something is. Selected unconditionally — the resolver returns 0 / [] / null when the blocker is inactive. `generated/` regenerated in step so the CI drift gate stays clean. Test fixture gains the new required fields, plus a case covering a blocked calendar — the shape that motivated the fields. --- .../graphql/generated/__init__.py | 4 ++ .../generated/get_ledger_fiscal_calendar.py | 24 +++++++++ .../graphql/generated/operations.py | 16 ++++++ .../ledger/GetLedgerFiscalCalendar.graphql | 6 +++ robosystems_client/graphql/schema.graphql | 4 +- .../models/event_block_envelope.py | 22 ++++---- tests/test_ledger_client.py | 54 +++++++++++++++++++ 7 files changed, 118 insertions(+), 12 deletions(-) diff --git a/robosystems_client/graphql/generated/__init__.py b/robosystems_client/graphql/generated/__init__.py index 1ba6472..552e9d1 100644 --- a/robosystems_client/graphql/generated/__init__.py +++ b/robosystems_client/graphql/generated/__init__.py @@ -75,7 +75,9 @@ from .get_ledger_fiscal_calendar import ( GetLedgerFiscalCalendar, GetLedgerFiscalCalendarFiscalCalendar, + GetLedgerFiscalCalendarFiscalCalendarPendingObligationSample, GetLedgerFiscalCalendarFiscalCalendarPeriods, + GetLedgerFiscalCalendarFiscalCalendarStrandedObligationSample, ) from .get_ledger_mapped_trial_balance import ( GetLedgerMappedTrialBalance, @@ -455,7 +457,9 @@ "GetLedgerEventBlockEventBlock", "GetLedgerFiscalCalendar", "GetLedgerFiscalCalendarFiscalCalendar", + "GetLedgerFiscalCalendarFiscalCalendarPendingObligationSample", "GetLedgerFiscalCalendarFiscalCalendarPeriods", + "GetLedgerFiscalCalendarFiscalCalendarStrandedObligationSample", "GetLedgerMappedTrialBalance", "GetLedgerMappedTrialBalanceMappedTrialBalance", "GetLedgerMappedTrialBalanceMappedTrialBalanceRows", diff --git a/robosystems_client/graphql/generated/get_ledger_fiscal_calendar.py b/robosystems_client/graphql/generated/get_ledger_fiscal_calendar.py index b61fe5e..4fea667 100644 --- a/robosystems_client/graphql/generated/get_ledger_fiscal_calendar.py +++ b/robosystems_client/graphql/generated/get_ledger_fiscal_calendar.py @@ -20,12 +20,36 @@ class GetLedgerFiscalCalendarFiscalCalendar(BaseModel): catch_up_sequence: list[str] = Field(alias="catchUpSequence") closeable_now: bool = Field(alias="closeableNow") blockers: list[str] + pending_obligation_count: int = Field(alias="pendingObligationCount") + pending_obligation_sample: list[ + "GetLedgerFiscalCalendarFiscalCalendarPendingObligationSample" + ] = Field(alias="pendingObligationSample") + earliest_pending_period: Optional[str] = Field(alias="earliestPendingPeriod") + stranded_obligation_count: int = Field(alias="strandedObligationCount") + stranded_obligation_sample: list[ + "GetLedgerFiscalCalendarFiscalCalendarStrandedObligationSample" + ] = Field(alias="strandedObligationSample") + sync_stale_days: Optional[int] = Field(alias="syncStaleDays") last_close_at: Optional[str] = Field(alias="lastCloseAt") initialized_at: Optional[str] = Field(alias="initializedAt") last_sync_at: Optional[str] = Field(alias="lastSyncAt") periods: list["GetLedgerFiscalCalendarFiscalCalendarPeriods"] +class GetLedgerFiscalCalendarFiscalCalendarPendingObligationSample(BaseModel): + event_id: str = Field(alias="eventId") + schedule_id: Optional[str] = Field(alias="scheduleId") + schedule_name: Optional[str] = Field(alias="scheduleName") + period: str + + +class GetLedgerFiscalCalendarFiscalCalendarStrandedObligationSample(BaseModel): + event_id: str = Field(alias="eventId") + schedule_id: Optional[str] = Field(alias="scheduleId") + schedule_name: Optional[str] = Field(alias="scheduleName") + period: str + + class GetLedgerFiscalCalendarFiscalCalendarPeriods(BaseModel): name: str start_date: str = Field(alias="startDate") diff --git a/robosystems_client/graphql/generated/operations.py b/robosystems_client/graphql/generated/operations.py index e30283a..66e64dc 100644 --- a/robosystems_client/graphql/generated/operations.py +++ b/robosystems_client/graphql/generated/operations.py @@ -633,6 +633,22 @@ catchUpSequence closeableNow blockers + pendingObligationCount + pendingObligationSample { + eventId + scheduleId + scheduleName + period + } + earliestPendingPeriod + strandedObligationCount + strandedObligationSample { + eventId + scheduleId + scheduleName + period + } + syncStaleDays lastCloseAt initializedAt lastSyncAt diff --git a/robosystems_client/graphql/operations/ledger/GetLedgerFiscalCalendar.graphql b/robosystems_client/graphql/operations/ledger/GetLedgerFiscalCalendar.graphql index 25781c4..57da333 100644 --- a/robosystems_client/graphql/operations/ledger/GetLedgerFiscalCalendar.graphql +++ b/robosystems_client/graphql/operations/ledger/GetLedgerFiscalCalendar.graphql @@ -2,6 +2,12 @@ query GetLedgerFiscalCalendar { fiscalCalendar { graphId fiscalYearStartMonth closedThrough closeTarget gapPeriods catchUpSequence closeableNow blockers + pendingObligationCount + pendingObligationSample { eventId scheduleId scheduleName period } + earliestPendingPeriod + strandedObligationCount + strandedObligationSample { eventId scheduleId scheduleName period } + syncStaleDays lastCloseAt initializedAt lastSyncAt periods { name startDate endDate status closedAt } } diff --git a/robosystems_client/graphql/schema.graphql b/robosystems_client/graphql/schema.graphql index 4cd45de..6351d42 100644 --- a/robosystems_client/graphql/schema.graphql +++ b/robosystems_client/graphql/schema.graphql @@ -15,7 +15,7 @@ type Query { openReceivablesByAgent: [OpenBalanceByAgent!]! openPayablesByAgent: [OpenBalanceByAgent!]! eventBlock(id: String!): EventBlock - eventBlocks(eventType: String = null, eventCategory: String = null, status: String = null, agentId: String = null, source: String = null, payloadDrift: Boolean = null, limit: Int = null, offset: Int = null): [EventBlock!]! + eventBlocks(eventType: String = null, eventCategory: String = null, status: String = null, agentId: String = null, source: String = null, isReconcilingItem: Boolean = null, limit: Int = null, offset: Int = null): [EventBlock!]! summary: LedgerSummary accounts(classification: String = null, isActive: Boolean = null, limit: Int = null, offset: Int = null): AccountList accountTree(includeInactive: Boolean = null): AccountTree @@ -636,7 +636,7 @@ type EventBlock { currency: String! description: String metadata: JSON! - payloadDrift: Boolean! + isReconcilingItem: Boolean! dimensionIds: [String!]! agentId: String resourceType: String diff --git a/robosystems_client/models/event_block_envelope.py b/robosystems_client/models/event_block_envelope.py index 517aa37..9865627 100644 --- a/robosystems_client/models/event_block_envelope.py +++ b/robosystems_client/models/event_block_envelope.py @@ -57,10 +57,12 @@ class EventBlockEnvelope: amount (int | None | Unset): Economic value in **cents** of `currency`, signed (inflows positive, outflows negative). `null` for non-economic events. description (None | str | Unset): Free-text human-readable summary. - payload_drift (bool | Unset): True when a source re-sync surfaced a changed upstream payload for an event whose - GL is already posted (committed/fulfilled are immutable to sync). The live payload and GL are untouched; the - incoming payload is stashed in `metadata.drift_payload` with `metadata.drift_detected_at`. Drifted events need - operator reconciliation — the local books no longer mirror the source. Default: False. + is_reconciling_item (bool | Unset): True when this event is a reconciling item: a source re-sync surfaced a + changed upstream payload for an event whose GL is already posted (committed/fulfilled are immutable to sync) — + the local books legitimately no longer mirror the source, and the difference awaits an explicit disposition + (restate the affected months, or book a catch-up entry in the open period). The live payload and GL are + untouched; the incoming payload is stashed in `metadata.drift_payload` with `metadata.drift_detected_at`. + Default: False. event_action (EventBlockEnvelopeEventActionType0 | None | Unset): Canonical action verb refining `event_category`. Null when the source adapter or capture path didn't supply one. agent_id (None | str | Unset): Counterparty agent ID, when the event involves one. @@ -94,7 +96,7 @@ class EventBlockEnvelope: external_url: None | str | Unset = UNSET amount: int | None | Unset = UNSET description: None | str | Unset = UNSET - payload_drift: bool | Unset = False + is_reconciling_item: bool | Unset = False event_action: EventBlockEnvelopeEventActionType0 | None | Unset = UNSET agent_id: None | str | Unset = UNSET resource_type: None | str | Unset = UNSET @@ -162,7 +164,7 @@ def to_dict(self) -> dict[str, Any]: else: description = self.description - payload_drift = self.payload_drift + is_reconciling_item = self.is_reconciling_item event_action: None | str | Unset if isinstance(self.event_action, Unset): @@ -242,8 +244,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["amount"] = amount if description is not UNSET: field_dict["description"] = description - if payload_drift is not UNSET: - field_dict["payload_drift"] = payload_drift + if is_reconciling_item is not UNSET: + field_dict["is_reconciling_item"] = is_reconciling_item if event_action is not UNSET: field_dict["event_action"] = event_action if agent_id is not UNSET: @@ -345,7 +347,7 @@ def _parse_description(data: object) -> None | str | Unset: description = _parse_description(d.pop("description", UNSET)) - payload_drift = d.pop("payload_drift", UNSET) + is_reconciling_item = d.pop("is_reconciling_item", UNSET) def _parse_event_action( data: object, @@ -455,7 +457,7 @@ def _parse_discharges_event_id(data: object) -> None | str | Unset: external_url=external_url, amount=amount, description=description, - payload_drift=payload_drift, + is_reconciling_item=is_reconciling_item, event_action=event_action, agent_id=agent_id, resource_type=resource_type, diff --git a/tests/test_ledger_client.py b/tests/test_ledger_client.py index 82f8748..27e130a 100644 --- a/tests/test_ledger_client.py +++ b/tests/test_ledger_client.py @@ -253,6 +253,12 @@ def test_get_fiscal_calendar(self, mock_execute, mock_config, graph_id): "catchUpSequence": [], "closeableNow": True, "blockers": [], + "pendingObligationCount": 0, + "pendingObligationSample": [], + "earliestPendingPeriod": None, + "strandedObligationCount": 0, + "strandedObligationSample": [], + "syncStaleDays": None, "lastCloseAt": None, "initializedAt": "2026-01-01T00:00:00Z", "lastSyncAt": None, @@ -265,6 +271,54 @@ def test_get_fiscal_calendar(self, mock_execute, mock_config, graph_id): assert cal.closed_through == "2026-02" assert cal.closeable_now is True assert cal.fiscal_year_start_month == 1 + assert cal.stranded_obligation_count == 0 + + @patch("robosystems_client.graphql.client.GraphQLClient.execute") + def test_get_fiscal_calendar_names_blocking_obligations( + self, mock_execute, mock_config, graph_id + ): + """A blocked calendar carries the detail needed to act on the blocker. + + `blockers` alone says a close is held but not by what; the sample names + the schedule so a caller can promote or void it without a second query. + """ + mock_execute.return_value = { + "fiscalCalendar": { + "graphId": graph_id, + "fiscalYearStartMonth": 1, + "closedThrough": "2026-02", + "closeTarget": "2026-03", + "gapPeriods": 1, + "catchUpSequence": ["2026-03"], + "closeableNow": False, + "blockers": ["stranded_obligations"], + "pendingObligationCount": 0, + "pendingObligationSample": [], + "earliestPendingPeriod": None, + "strandedObligationCount": 2, + "strandedObligationSample": [ + { + "eventId": "evt_1", + "scheduleId": "str_prepaid", + "scheduleName": "Prepaid Insurance", + "period": "2026-03", + } + ], + "syncStaleDays": None, + "lastCloseAt": None, + "initializedAt": "2026-01-01T00:00:00Z", + "lastSyncAt": None, + "periods": [], + } + } + client = LedgerClient(mock_config) + cal = client.get_fiscal_calendar(graph_id) + assert cal is not None + assert cal.closeable_now is False + assert cal.blockers == ["stranded_obligations"] + assert cal.stranded_obligation_count == 2 + assert cal.stranded_obligation_sample[0].schedule_name == "Prepaid Insurance" + assert cal.stranded_obligation_sample[0].period == "2026-03" # ── Writes (Operation envelope) ────────────────────────────────────────