From 84d0e1e0d5347ad8c70027424f99474bd9d03233 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 08:51:39 +0000 Subject: [PATCH] =?UTF-8?q?Date=20an=20abandonment=20to=20the=20deal's=20o?= =?UTF-8?q?wn=20dates,=20not=20now()=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit date_abandoned now resolves to the third failed attempt's confirmed survey date, falling back to its last submission date, rather than the processing date. This dates the OpenHousing cancellation to when the job actually lapsed even if the trigger message lags or redelivers. - domain: abandonment_date() encodes the confirmed-survey -> last- submission fallback - DealAbandonment carries both dates; abandon_job raises AbandonmentDateUnknownError when a deal has neither - last_submission_date now flows through the trigger message and request - the injected clock is dropped: nothing reads now() any more Co-Authored-By: Claude Opus 4.8 --- applications/abri/abri_trigger_request.py | 7 +- applications/abri/dispatch.py | 2 + domain/abri/abandonment.py | 15 ++++ etl/hubspot/hubspot_deal_differ.py | 8 ++ etl/hubspot/tests/test_abri_flow_triggers.py | 11 ++- orchestration/abri_orchestrator.py | 32 +++++-- .../abri/test_abri_trigger_request.py | 13 +++ tests/applications/abri/test_dispatch.py | 13 ++- tests/domain/abri/test_abandonment.py | 18 +++- .../test_abri_orchestrator_abandon_job.py | 85 ++++++++++++++++--- 10 files changed, 180 insertions(+), 24 deletions(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index f272be7bd..92ec5b8c0 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -14,8 +14,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] # The deal fields each flow reads; hubspot_deal_id is always required. -# abandon_job needs no field beyond the deal id (the outcome is optional, only -# feeding the reason-mapping seam). +# abandon_job requires no single field: it dates the cancel to whichever of the +# confirmed survey date or last submission date is present, so requiring either +# alone would reject valid messages; the orchestrator fails the flow if a deal +# carries neither. Its outcome is optional too, only feeding the reason seam. _FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = { "amend_job": ("confirmed_survey_date",), "log_job": ("place_ref", "deal_name", "confirmed_survey_date"), @@ -33,6 +35,7 @@ class AbriTriggerRequest(BaseModel): deal_name: Optional[str] = None confirmed_survey_date: Optional[date] = None confirmed_survey_time: Optional[str] = None + last_submission_date: Optional[date] = None outcome: Optional[str] = None @model_validator(mode="after") diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 00f9223cc..6187c6ea3 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -140,6 +140,8 @@ def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str: DealAbandonment( deal_id=request.hubspot_deal_id, outcome=request.outcome, + confirmed_survey_date=request.confirmed_survey_date, + last_submission_date=request.last_submission_date, ) ) if isinstance(outcome, AbriRequestRejected): diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index 400c184ae..abf6e638e 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -1,8 +1,23 @@ +from datetime import date from typing import Optional from domain.abri.models import AbandonReason +def abandonment_date( + confirmed_survey_date: Optional[date], + last_submission_date: Optional[date], +) -> Optional[date]: + """The real-world date a deal's job is abandoned on. + + The confirmed survey date of the failed third attempt, since that is when + the job actually lapsed; if no survey date was ever confirmed, the date the + third attempt was last submitted stands in. Returns None when the deal + carries neither date, leaving the caller to decide how to surface the gap. + """ + return confirmed_survey_date or last_submission_date + + def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: """The OpenHousing abandonment reason code for a HubSpot deal outcome. diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 99950c648..eb48fd464 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -219,6 +219,7 @@ class HubspotDealDiffer: return None confirmed_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) + last_submission_date = parse_hs_date(new_deal.get("last_submission_date")) listing = new_listing or {} return { "hubspot_deal_id": hubspot_deal_id, @@ -231,6 +232,13 @@ class HubspotDealDiffer: else None ), "confirmed_survey_time": new_deal.get("confirmed_survey_time"), + # Carried for the abandon flow: it dates the cancel to the confirmed + # survey date, falling back to the last submission date. + "last_submission_date": ( + last_submission_date.date().isoformat() + if last_submission_date is not None + else None + ), # Carried for the abandon flow's reason-mapping seam; harmless to # the other flows, which ignore it. "outcome": new_deal.get("outcome"), diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index 91b3fcc59..0e7eb320b 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -62,6 +62,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "last_submission_date": None, "outcome": None, } @@ -192,7 +193,11 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non old_deal = make_old_deal( project_code=ABRI_PROJECT_CODE, number_of_attempts="2", outcome=None ) - new_deal = make_new_deal(number_of_attempts="3", outcome="no answer") + # No confirmed survey date (an abandonment that never got surveyed), so the + # last submission date is what the cancel will be dated to. + new_deal = make_new_deal( + number_of_attempts="3", outcome="no answer", last_submission_date="2026-07-01" + ) # Act message = HubspotDealDiffer.check_abri_triggers_and_construct_message( @@ -203,10 +208,12 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non old_deal=old_deal, ) - # Assert + # Assert: the abandon flow carries the date it will date the cancel from. assert message is not None assert message["flows"] == ["abandon_job"] assert message["outcome"] == "no answer" + assert message["confirmed_survey_date"] is None + assert message["last_submission_date"] == "2026-07-01" # ========================== diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index 0de04f0dc..c0aba0c93 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from datetime import date -from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union +from typing import Dict, List, Optional, Protocol, Tuple, Union -from domain.abri.abandonment import abandon_reason_for_outcome +from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( AbandonJobRequest, @@ -149,11 +149,29 @@ class DealAbandonment: deal_id: str outcome: Optional[str] + confirmed_survey_date: Optional[date] + last_submission_date: Optional[date] AbandonJobOrchestrationResult = Union[JobAbandoned, AbriRequestRejected] +class AbandonmentDateUnknownError(Exception): + """An abandonment whose real-world date cannot be resolved from the deal. + + The cancel is dated to the third attempt's confirmed survey date, falling + back to its last submission date; a deal carrying neither leaves nothing to + date the cancellation to, so the flow fails rather than invent a date. + """ + + def __init__(self, deal_id: str) -> None: + self.deal_id = deal_id + super().__init__( + f"deal {deal_id} has no confirmed survey date or last submission " + "date to date its abandonment to" + ) + + class JobNoNotYetRecordedError(Exception): """An amendment arrived before the deal's job_no landed in the database. @@ -208,13 +226,11 @@ class AbriOrchestrator: deal_contacts: HubspotDealContactsClient, deal_properties: HubspotDealPropertiesClient, deal_database: DealDatabaseGateway, - clock: Callable[[], date] = date.today, ) -> None: self._abri = abri_client self._deal_contacts = deal_contacts self._deal_properties = deal_properties self._deal_database = deal_database - self._clock = clock def sync_tenant_data( self, place_ref: PlaceRef, deal_id: str @@ -277,11 +293,17 @@ class AbriOrchestrator: if job_no is None: raise JobNoNotYetRecordedError(deal_id=abandonment.deal_id) + date_abandoned = abandonment_date( + abandonment.confirmed_survey_date, abandonment.last_submission_date + ) + if date_abandoned is None: + raise AbandonmentDateUnknownError(deal_id=abandonment.deal_id) + return self._abri.abandon_job( AbandonJobRequest( job_no=job_no, reason=abandon_reason_for_outcome(abandonment.outcome), - date_abandoned=self._clock(), + date_abandoned=date_abandoned, ) ) diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 24baca130..5d723e3f0 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -110,6 +110,19 @@ def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> N assert request.outcome == "no answer" +def test_a_message_carries_the_last_submission_date_for_abandonment_dating() -> None: + # Arrange + message = _full_message() + message["flows"] = ["abandon_job"] + message["last_submission_date"] = "2026-07-01" + + # Act + request = AbriTriggerRequest.model_validate(message) + + # Assert + assert request.last_submission_date == date(2026, 7, 1) + + def test_a_message_naming_an_unknown_flow_fails_validation() -> None: # Arrange message = _full_message() diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index 9eac4fed7..4b7090ae2 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -38,6 +38,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "last_submission_date": "2026-07-01", "outcome": "no answer", } ) @@ -275,7 +276,7 @@ def test_abandon_runs_last_in_the_fixed_dispatch_order( ] -def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message( +def test_the_abandon_flow_receives_the_deal_fields_it_dates_the_cancel_from( orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase ) -> None: # Arrange @@ -286,7 +287,15 @@ def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message( # Assert assert orchestrator.calls == [ - ("abandon_job", DealAbandonment(deal_id=DEAL_ID, outcome="no answer")) + ( + "abandon_job", + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=date(2026, 6, 24), + last_submission_date=date(2026, 7, 1), + ), + ) ] diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py index 4c0cda5f1..2fbeeb3e5 100644 --- a/tests/domain/abri/test_abandonment.py +++ b/tests/domain/abri/test_abandonment.py @@ -1,10 +1,26 @@ +from datetime import date from typing import Optional import pytest -from domain.abri.abandonment import abandon_reason_for_outcome +from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date from domain.abri.models import AbandonReason +CONFIRMED = date(2026, 6, 24) +SUBMITTED = date(2026, 7, 1) + + +def test_the_confirmed_survey_date_dates_the_abandonment() -> None: + assert abandonment_date(CONFIRMED, SUBMITTED) == CONFIRMED + + +def test_the_last_submission_date_stands_in_without_a_confirmed_survey_date() -> None: + assert abandonment_date(None, SUBMITTED) == SUBMITTED + + +def test_neither_date_leaves_the_abandonment_undated() -> None: + assert abandonment_date(None, None) is None + @pytest.mark.parametrize( "outcome", diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py index c12f4681b..6243bcb9f 100644 --- a/tests/orchestration/test_abri_orchestrator_abandon_job.py +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -12,6 +12,7 @@ from infrastructure.abri.config import AbriConfig from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient from orchestration.abri_orchestrator import ( + AbandonmentDateUnknownError, AbriOrchestrator, DealAbandonment, JobNoNotYetRecordedError, @@ -21,7 +22,8 @@ FIXTURE_DIR = Path(__file__).parents[1] / "abri" ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" DEAL_ID = "9876543210" JOB_NO = "AC0439951" -TODAY = date(2026, 7, 7) +CONFIRMED_SURVEY_DATE = date(2026, 6, 24) +LAST_SUBMISSION_DATE = date(2026, 7, 1) CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, @@ -30,7 +32,12 @@ CONFIG = AbriConfig( default_resource="NAULKH", ) -ABANDONMENT = DealAbandonment(deal_id=DEAL_ID, outcome="no answer") +ABANDONMENT = DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=CONFIRMED_SURVEY_DATE, + last_submission_date=LAST_SUBMISSION_DATE, +) def _load_fixture(name: str) -> bytes: @@ -78,14 +85,21 @@ def orchestrator( deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client), deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client), deal_database=deal_database, - clock=lambda: TODAY, ) -# --- outbound request: canceljob carries the job, reason and today's date --- +# --- outbound request: canceljob dates the abandonment to the survey date --- -def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( +def _sent_parameters(mock_session: MagicMock) -> Dict[Optional[str], Optional[str]]: + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + return { + parameter.get("attribute"): parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") + } + + +def test_abandon_job_sends_a_canceljob_dated_to_the_confirmed_survey_date( orchestrator: AbriOrchestrator, mock_session: MagicMock, deal_database: FakeDealDatabase, @@ -101,19 +115,66 @@ def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( # Assert (url,) = mock_session.post.call_args.args - sent_body: bytes = mock_session.post.call_args.kwargs["data"] - sent_parameters = { - parameter.get("attribute"): parameter.get("attribute_value") - for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") - } assert url == ENDPOINT_URL - assert sent_parameters == { + assert _sent_parameters(mock_session) == { "job_no": JOB_NO, "abandon_reason": "CARDED", - "date_abandoned": "07/07/2026", + "date_abandoned": "24/06/2026", } +# --- fallback: no confirmed survey date, so the last submission date dates it --- + + +def test_abandon_job_falls_back_to_the_last_submission_date( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + orchestrator.abandon_job( + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=None, + last_submission_date=LAST_SUBMISSION_DATE, + ) + ) + + # Assert + assert _sent_parameters(mock_session)["date_abandoned"] == "01/07/2026" + + +# --- neither date present: nothing to date the abandonment to --- + + +def test_abandon_job_raises_when_no_date_can_be_resolved( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + + # Act / Assert + with pytest.raises(AbandonmentDateUnknownError): + orchestrator.abandon_job( + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=None, + last_submission_date=None, + ) + ) + assert mock_session.post.call_count == 0 + + # --- happy path: the cancelled job is confirmed back ---