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 ---