Date an abandonment to the deal's own dates, not now() 🟩

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-08 08:51:39 +00:00
parent 9ec629daf1
commit 84d0e1e0d5
10 changed files with 180 additions and 24 deletions

View file

@ -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")

View file

@ -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):

View file

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

View file

@ -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"),

View file

@ -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"
# ==========================

View file

@ -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,
)
)

View file

@ -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()

View file

@ -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),
),
)
]

View file

@ -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",

View file

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