diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml
index 228b1fcea..8b9418efc 100644
--- a/.github/workflows/deploy_terraform.yml
+++ b/.github/workflows/deploy_terraform.yml
@@ -707,7 +707,7 @@ jobs:
needs: [determine_stage, shared_terraform]
uses: ./.github/workflows/_build_image.yml
with:
- ecr_repo: abri-${{ needs.determine_stage.outputs.stage }}
+ ecr_repo: abri_api-${{ needs.determine_stage.outputs.stage }}
dockerfile_path: applications/abri/handler/Dockerfile
build_context: .
secrets:
@@ -722,10 +722,10 @@ jobs:
needs: [abri_image, determine_stage]
uses: ./.github/workflows/_deploy_lambda.yml
with:
- lambda_name: abri
- lambda_path: deployment/terraform/lambda/abri
+ lambda_name: abri_api
+ lambda_path: deployment/terraform/lambda/abri_api
stage: ${{ needs.determine_stage.outputs.stage }}
- ecr_repo: abri-${{ needs.determine_stage.outputs.stage }}
+ ecr_repo: abri_api-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.abri_image.outputs.image_digest }}
terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }}
secrets:
diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py
index 0a9346ca9..92ec5b8c0 100644
--- a/applications/abri/abri_trigger_request.py
+++ b/applications/abri/abri_trigger_request.py
@@ -11,13 +11,18 @@ from typing import Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, ConfigDict, Field, model_validator
-AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data"]
+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 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"),
"sync_tenant_data": ("place_ref",),
+ "abandon_job": (),
}
@@ -30,6 +35,8 @@ 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")
def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest":
diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py
index f69981162..6187c6ea3 100644
--- a/applications/abri/dispatch.py
+++ b/applications/abri/dispatch.py
@@ -1,11 +1,13 @@
"""Dispatches the flows named in a trigger message through the orchestrator.
The scraper decides which flows fire; this is a dumb dispatcher. Flows run
-in a fixed, retry-safe order — amend, then log, then tenant sync — so that
-redelivery re-runs idempotent flows before non-idempotent ones: amend is
-naturally idempotent, log is guarded by the job-number check, and tenant
-contact creation (not idempotent) runs last so its failure can never cause
-a duplicate job log, and a log failure can never duplicate contacts.
+in a fixed, retry-safe order — amend, then log, then tenant sync, then
+abandon — so that redelivery re-runs idempotent flows before non-idempotent
+ones: amend is naturally idempotent, log is guarded by the job-number check,
+and tenant contact creation (not idempotent) runs before abandon so its
+failure can never cause a duplicate job log, and a log failure can never
+duplicate contacts. Abandon runs last so a cancellation can never interfere
+with a log, amend or tenant sync in the same batch.
"""
from typing import Dict, Protocol
@@ -13,9 +15,11 @@ from typing import Dict, Protocol
from applications.abri.abri_trigger_request import AbriTriggerRequest
from domain.abri.models import AbriRequestRejected, PlaceRef
from orchestration.abri_orchestrator import (
+ AbandonJobOrchestrationResult,
AmendJobOrchestrationResult,
AppointmentChange,
ConfirmedSurveyBooking,
+ DealAbandonment,
DealDatabaseGateway,
LogJobOrchestrationResult,
LogJobWriteBackError,
@@ -35,6 +39,10 @@ class AbriFlows(Protocol):
self, place_ref: PlaceRef, deal_id: str
) -> TenantDataSyncResult: ...
+ def abandon_job(
+ self, abandonment: DealAbandonment
+ ) -> AbandonJobOrchestrationResult: ...
+
class AbriFlowRejectedError(Exception):
"""An OpenHousing rejection, surfaced as a failure so the task fails.
@@ -66,6 +74,8 @@ def dispatch_abri_flows(
summary["log_job"] = _run_log(request, flows, deal_database)
if "sync_tenant_data" in request.flows:
summary["sync_tenant_data"] = _run_tenant_sync(request, flows)
+ if "abandon_job" in request.flows:
+ summary["abandon_job"] = _run_abandon(request, flows)
return summary
@@ -122,6 +132,23 @@ def _run_log(
return f"job {outcome.job_no} logged"
+def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str:
+ # Runs last so a cancellation can never interfere with a log, amend or
+ # tenant sync in the same batch. A missing job_no raises the retriable
+ # not-yet-recorded error, which propagates so the task fails and redelivers.
+ outcome = flows.abandon_job(
+ 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):
+ raise AbriFlowRejectedError(flow="abandon_job", rejection=outcome)
+ return f"job {outcome.job_no} abandoned"
+
+
def _run_tenant_sync(request: AbriTriggerRequest, flows: AbriFlows) -> str:
if request.place_ref is None:
raise ValueError("sync_tenant_data fired without a place_ref")
diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py
new file mode 100644
index 000000000..abf6e638e
--- /dev/null
+++ b/domain/abri/abandonment.py
@@ -0,0 +1,28 @@
+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.
+
+ The single mapping point from HubSpot's outcome to a coded reason. For now
+ every outcome maps to CARDED; the real outcome-to-code table is a follow-up
+ (needs Abri's code list), and swapping it in is a one-place change here.
+ """
+ return AbandonReason.CARDED
diff --git a/domain/abri/models.py b/domain/abri/models.py
index d96d27fda..a17cf82d5 100644
--- a/domain/abri/models.py
+++ b/domain/abri/models.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import date
+from enum import Enum
from typing import Literal, NewType, Optional, Tuple, Union
PlaceRef = NewType("PlaceRef", str)
@@ -7,6 +8,16 @@ PlaceRef = NewType("PlaceRef", str)
SlotCode = Literal["AM", "PM", "AD"]
+class AbandonReason(str, Enum):
+ """OpenHousing's coded reason a job was abandoned.
+
+ Only CARDED is used for now; the full HubSpot-outcome-to-code table is a
+ documented follow-up - enum based on Abri codes needs creating and mapping from hubspot
+ """
+
+ CARDED = "CARDED"
+
+
@dataclass(frozen=True)
class LogJobRequest:
client_ref: str
@@ -78,3 +89,18 @@ class TenancyData:
GetTenantDataResult = Union[TenancyData, AbriRequestRejected]
+
+
+@dataclass(frozen=True)
+class AbandonJobRequest:
+ job_no: str
+ reason: AbandonReason
+ date_abandoned: date
+
+
+@dataclass(frozen=True)
+class JobAbandoned:
+ job_no: str
+
+
+AbandonJobResult = Union[JobAbandoned, AbriRequestRejected]
diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py
index e5b32ce29..eb48fd464 100644
--- a/etl/hubspot/hubspot_deal_differ.py
+++ b/etl/hubspot/hubspot_deal_differ.py
@@ -210,11 +210,16 @@ class HubspotDealDiffer:
new_deal=new_deal, new_project=new_project, old_deal=old_deal
):
flows.append("sync_tenant_data")
+ if HubspotDealDiffer.check_for_abri_deal_abandonment(
+ new_deal=new_deal, new_project=new_project, old_deal=old_deal
+ ):
+ flows.append("abandon_job")
if not flows:
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,
@@ -227,6 +232,16 @@ 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"),
}
@staticmethod
diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py
index c6868b70b..0e7eb320b 100644
--- a/etl/hubspot/tests/test_abri_flow_triggers.py
+++ b/etl/hubspot/tests/test_abri_flow_triggers.py
@@ -3,7 +3,7 @@ from typing import Any, Dict
import uuid
from backend.app.db.models.hubspot_deal_data import HubspotDealData
-from etl.hubspot.abri_flow_triggers import check_for_abri_triggers_and_construct_message
+from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer
BASE_TIME = datetime(2025, 12, 1, 12, 0, 0)
@@ -46,7 +46,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
)
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -62,6 +62,8 @@ 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,
}
@@ -76,7 +78,7 @@ def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
)
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -98,7 +100,7 @@ def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> No
new_deal = make_new_deal(expected_commencement_date="2026-07-01")
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -131,7 +133,7 @@ def test_several_flows_firing_in_one_scrape_share_one_message() -> None:
)
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -155,7 +157,7 @@ def test_no_message_when_no_abri_trigger_fires() -> None:
new_deal = make_new_deal(outcome="left voicemail")
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -173,7 +175,7 @@ def test_no_message_for_a_non_abri_deal() -> None:
new_deal = make_new_deal(project_code="Other", confirmed_survey_date="2026-06-24")
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -185,16 +187,20 @@ def test_no_message_for_a_non_abri_deal() -> None:
assert message is None
-def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None:
- # Arrange: the abandonment predicate would fire, but CancelJob does not
- # exist yet, so no flow may be dispatched for it.
+def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> None:
+ # Arrange: attempts reach the threshold with a negative outcome, so the
+ # deal crosses into the abandoned state and the abandon flow fires.
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 = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
@@ -202,8 +208,12 @@ def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None:
old_deal=old_deal,
)
- # Assert
- assert message is None
+ # 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"
# ==========================
@@ -218,7 +228,7 @@ def test_a_missing_listing_still_builds_the_message_without_a_place_ref() -> Non
new_deal = make_new_deal(confirmed_survey_date="2026-06-24")
# Act
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py
index 80f9cde77..7c9c018a9 100644
--- a/infrastructure/abri/abri_client.py
+++ b/infrastructure/abri/abri_client.py
@@ -5,11 +5,14 @@ from typing import Dict, List, Tuple, Union
import requests
from domain.abri.models import (
+ AbandonJobRequest,
+ AbandonJobResult,
AbriRequestRejected,
AmendJobRequest,
AmendJobResult,
AppointmentAmended,
GetTenantDataResult,
+ JobAbandoned,
JobLogged,
LogJobRequest,
LogJobResult,
@@ -94,6 +97,21 @@ class AbriClient:
return self._parse_appointment_amended(outcome)
+ def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult:
+ outcome = self._exchange(
+ request_type="canceljob",
+ parameters=[
+ ("job_no", request.job_no),
+ ("abandon_reason", request.reason.value),
+ ("date_abandoned", _format_appointment_date(request.date_abandoned)),
+ ],
+ )
+
+ if isinstance(outcome, AbriRequestRejected):
+ return outcome
+
+ return self._parse_abandon_result(outcome)
+
def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
outcome = self._exchange(
request_type="getSCSTenantData",
@@ -191,6 +209,41 @@ class AbriClient:
appointment_time=appointment_time,
)
+ @staticmethod
+ def _parse_abandon_result(root: ET.Element) -> AbandonJobResult:
+ # canceljob diverges from the other flows: failure can arrive as a
+ # result document whose cancellation collection is empty and which
+ # carries an ErrorDetails element, rather than the shared failure
+ # envelope. A present JobCancelled is success; a present ErrorDetails
+ # is a business rejection. Neither present is genuinely ambiguous and
+ # must stay retriable — never silently a success or a rejection.
+ job_cancelled = root.find(".//JobCancelled")
+ if job_cancelled is not None:
+ job_no = job_cancelled.get("job_no")
+ if job_no is None:
+ raise AbriResponseParseError("JobCancelled element missing job_no")
+ return JobAbandoned(job_no=job_no)
+
+ error_details = root.find(".//ErrorDetails")
+ if error_details is not None:
+ return AbriClient._rejection_from_error_details(error_details)
+
+ raise AbriResponseParseError(
+ "canceljob response has neither JobCancelled nor ErrorDetails"
+ )
+
+ @staticmethod
+ def _rejection_from_error_details(error_details: ET.Element) -> AbriRequestRejected:
+ code = error_details.get("code")
+ message = error_details.get("message")
+
+ if code is None or message is None:
+ raise AbriResponseParseError(
+ "canceljob ErrorDetails missing code or message"
+ )
+
+ return AbriRequestRejected(code=code, message=message)
+
@staticmethod
def _parse_tenancy_data(root: ET.Element) -> TenancyData:
tenancies = root.findall("Tenancy")
diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py
index d4b61b8ba..c0aba0c93 100644
--- a/orchestration/abri_orchestrator.py
+++ b/orchestration/abri_orchestrator.py
@@ -2,11 +2,14 @@ from dataclasses import dataclass
from datetime import date
from typing import Dict, List, Optional, Protocol, Tuple, Union
+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,
AbriRequestRejected,
AmendJobRequest,
AppointmentAmended,
+ JobAbandoned,
LogJobRequest,
PlaceRef,
Tenant,
@@ -140,6 +143,35 @@ class AppointmentChange:
AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected]
+@dataclass(frozen=True)
+class DealAbandonment:
+ """The deal fields a crossed-into-abandoned deal contributes to a cancel."""
+
+ 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.
@@ -254,6 +286,27 @@ class AbriOrchestrator:
)
)
+ def abandon_job(
+ self, abandonment: DealAbandonment
+ ) -> AbandonJobOrchestrationResult:
+ job_no = self._deal_database.job_no_for_deal(abandonment.deal_id)
+ 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=date_abandoned,
+ )
+ )
+
def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult:
descriptions = build_job_descriptions(booking.deal_name)
outcome = self._abri.log_job(
diff --git a/scripts/smoke_test_abri_logjob_flow.py b/scripts/smoke_test_abri_logjob_flow.py
index 36f0f40e8..61bae5bee 100644
--- a/scripts/smoke_test_abri_logjob_flow.py
+++ b/scripts/smoke_test_abri_logjob_flow.py
@@ -129,9 +129,7 @@ def main() -> None:
from applications.abri.abri_trigger_request import AbriTriggerRequest
from applications.abri.dispatch import dispatch_abri_flows
from backend.app.db.models.hubspot_deal_data import HubspotDealData
- from etl.hubspot.abri_flow_triggers import (
- check_for_abri_triggers_and_construct_message,
- )
+ from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer
from etl.hubspot.hubspotClient import HubspotClient
from infrastructure.abri.abri_client import AbriClient
from infrastructure.abri.config import AbriConfig
@@ -198,7 +196,7 @@ def main() -> None:
f"job_no={deal_row.client_booking_reference}"
)
- message = check_for_abri_triggers_and_construct_message(
+ message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=hubspot_deal,
new_project=project,
diff --git a/tests/abri/abri_relay_canceljob_errordetails_response.xml b/tests/abri/abri_relay_canceljob_errordetails_response.xml
new file mode 100644
index 000000000..674e0bfa9
--- /dev/null
+++ b/tests/abri/abri_relay_canceljob_errordetails_response.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/abri/abri_relay_canceljob_relayerror_response.xml b/tests/abri/abri_relay_canceljob_relayerror_response.xml
new file mode 100644
index 000000000..225563172
--- /dev/null
+++ b/tests/abri/abri_relay_canceljob_relayerror_response.xml
@@ -0,0 +1,6 @@
+
+
+ false
+ RelayError
+ Job_no 1019905 is invalid. Job not found.
+
diff --git a/tests/abri/abri_relay_canceljob_request_example.xml b/tests/abri/abri_relay_canceljob_request_example.xml
new file mode 100644
index 000000000..cab9323bf
--- /dev/null
+++ b/tests/abri/abri_relay_canceljob_request_example.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/abri/abri_relay_canceljob_success_response.xml b/tests/abri/abri_relay_canceljob_success_response.xml
new file mode 100644
index 000000000..5d841e494
--- /dev/null
+++ b/tests/abri/abri_relay_canceljob_success_response.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py
index 9e9672a2b..5d723e3f0 100644
--- a/tests/applications/abri/test_abri_trigger_request.py
+++ b/tests/applications/abri/test_abri_trigger_request.py
@@ -83,6 +83,46 @@ def test_a_message_without_a_confirmed_time_parses_for_logging() -> None:
assert request.confirmed_survey_time is None
+def test_a_message_naming_abandon_job_validates_with_only_the_deal_id() -> None:
+ # Arrange: abandonment needs no deal field beyond the deal id.
+ message = {
+ "hubspot_deal_id": "9876543210",
+ "flows": ["abandon_job"],
+ }
+
+ # Act
+ request = AbriTriggerRequest.model_validate(message)
+
+ # Assert
+ assert request.flows == ["abandon_job"]
+
+
+def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None:
+ # Arrange
+ message = _full_message()
+ message["flows"] = ["abandon_job"]
+ message["outcome"] = "no answer"
+
+ # Act
+ request = AbriTriggerRequest.model_validate(message)
+
+ # Assert
+ 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 b41aa4d4b..4b7090ae2 100644
--- a/tests/applications/abri/test_dispatch.py
+++ b/tests/applications/abri/test_dispatch.py
@@ -5,11 +5,19 @@ import pytest
from applications.abri.abri_trigger_request import AbriTriggerRequest
from applications.abri.dispatch import AbriFlowRejectedError, dispatch_abri_flows
-from domain.abri.models import AbriRequestRejected, AppointmentAmended, PlaceRef
+from domain.abri.models import (
+ AbriRequestRejected,
+ AppointmentAmended,
+ JobAbandoned,
+ PlaceRef,
+)
from orchestration.abri_orchestrator import (
+ AbandonJobOrchestrationResult,
AmendJobOrchestrationResult,
AppointmentChange,
ConfirmedSurveyBooking,
+ DealAbandonment,
+ JobNoNotYetRecordedError,
LogJobOrchestrationResult,
LogJobSummary,
LogJobWriteBackError,
@@ -30,6 +38,8 @@ 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",
}
)
@@ -53,6 +63,10 @@ class FakeOrchestrator:
contact_ids=("101", "102"),
vulnerable_contact_count=1,
)
+ self.abandon_outcome: AbandonJobOrchestrationResult = JobAbandoned(
+ job_no="AC0439951"
+ )
+ self.abandon_error: Optional[Exception] = None
def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult:
self.calls.append(("amend_job", change))
@@ -70,6 +84,14 @@ class FakeOrchestrator:
self.calls.append(("sync_tenant_data", place_ref, deal_id))
return self.sync_outcome
+ def abandon_job(
+ self, abandonment: DealAbandonment
+ ) -> AbandonJobOrchestrationResult:
+ self.calls.append(("abandon_job", abandonment))
+ if self.abandon_error is not None:
+ raise self.abandon_error
+ return self.abandon_outcome
+
class FakeDealDatabase:
"""In-memory stand-in for the deal-database gateway."""
@@ -233,6 +255,78 @@ def test_a_log_write_back_failure_raises_a_non_retriable_error(
assert [call[0] for call in orchestrator.calls] == ["log_job"]
+# --- abandon runs last, after amend, log and tenant sync ---
+
+
+def test_abandon_runs_last_in_the_fixed_dispatch_order(
+ orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
+) -> None:
+ # Arrange
+ request = _request(["abandon_job", "sync_tenant_data", "log_job", "amend_job"])
+
+ # Act
+ dispatch_abri_flows(request, orchestrator, deal_database)
+
+ # Assert
+ assert [call[0] for call in orchestrator.calls] == [
+ "amend_job",
+ "log_job",
+ "sync_tenant_data",
+ "abandon_job",
+ ]
+
+
+def test_the_abandon_flow_receives_the_deal_fields_it_dates_the_cancel_from(
+ orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
+) -> None:
+ # Arrange
+ request = _request(["abandon_job"])
+
+ # Act
+ dispatch_abri_flows(request, orchestrator, deal_database)
+
+ # Assert
+ assert orchestrator.calls == [
+ (
+ "abandon_job",
+ DealAbandonment(
+ deal_id=DEAL_ID,
+ outcome="no answer",
+ confirmed_survey_date=date(2026, 6, 24),
+ last_submission_date=date(2026, 7, 1),
+ ),
+ )
+ ]
+
+
+def test_an_abandon_rejection_raises_so_the_task_fails(
+ orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
+) -> None:
+ # Arrange
+ request = _request(["abandon_job"])
+ orchestrator.abandon_outcome = AbriRequestRejected(
+ code="RelayError", message="Job_no AC0439951 is invalid. Job not found."
+ )
+
+ # Act / Assert
+ with pytest.raises(AbriFlowRejectedError) as exc_info:
+ dispatch_abri_flows(request, orchestrator, deal_database)
+ assert "abandon_job" in str(exc_info.value)
+ assert "RelayError" in str(exc_info.value)
+
+
+def test_an_abandon_before_the_job_no_is_recorded_propagates_the_retriable_error(
+ orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
+) -> None:
+ # Arrange
+ request = _request(["abandon_job"])
+ orchestrator.abandon_error = JobNoNotYetRecordedError(deal_id=DEAL_ID)
+
+ # Act / Assert
+ with pytest.raises(JobNoNotYetRecordedError):
+ dispatch_abri_flows(request, orchestrator, deal_database)
+
+
# --- the task output is a PII-free summary of what ran ---
@@ -252,3 +346,16 @@ def test_dispatch_returns_a_pii_free_summary_of_what_ran(
"log_job": "skipped: job AC0439951 already logged",
"sync_tenant_data": "2 contacts created (1 vulnerable)",
}
+
+
+def test_the_summary_records_an_abandoned_job(
+ orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
+) -> None:
+ # Arrange
+ request = _request(["abandon_job"])
+
+ # Act
+ summary = dispatch_abri_flows(request, orchestrator, deal_database)
+
+ # Assert
+ assert summary == {"abandon_job": "job AC0439951 abandoned"}
diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py
new file mode 100644
index 000000000..2fbeeb3e5
--- /dev/null
+++ b/tests/domain/abri/test_abandonment.py
@@ -0,0 +1,36 @@
+from datetime import date
+from typing import Optional
+
+import pytest
+
+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",
+ ["no answer", "cancelled", "no show", "tenant refusal", "not viable", None],
+)
+def test_every_outcome_currently_maps_to_the_carded_reason(
+ outcome: Optional[str],
+) -> None:
+ # Act
+ reason = abandon_reason_for_outcome(outcome)
+
+ # Assert
+ assert reason == AbandonReason.CARDED
diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py
index 6ab6c7698..fff0a3865 100644
--- a/tests/infrastructure/abri/test_abri_client.py
+++ b/tests/infrastructure/abri/test_abri_client.py
@@ -7,9 +7,12 @@ import pytest
import requests
from domain.abri.models import (
+ AbandonJobRequest,
+ AbandonReason,
AbriRequestRejected,
AmendJobRequest,
AppointmentAmended,
+ JobAbandoned,
JobLogged,
LogJobRequest,
PlaceRef,
@@ -60,6 +63,14 @@ def _spec_example_amend_request() -> AmendJobRequest:
)
+def _spec_example_abandon_request() -> AbandonJobRequest:
+ return AbandonJobRequest(
+ job_no="AC0439951",
+ reason=AbandonReason.CARDED,
+ date_abandoned=date(2026, 7, 7),
+ )
+
+
@pytest.fixture()
def mock_session() -> MagicMock:
return MagicMock()
@@ -118,6 +129,119 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint(
)
+# --- abandon_job: rejection (canceljob's own error-details shape) ---
+
+
+def test_abandon_job_returns_rejection_from_the_canceljob_error_details_document(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange: canceljob signals failure as an empty cancellation collection
+ # carrying an ErrorDetails element, not the shared failure envelope.
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_canceljob_errordetails_response.xml"
+ )
+
+ # Act
+ result = client.abandon_job(_spec_example_abandon_request())
+
+ # Assert
+ assert result == AbriRequestRejected(
+ code="RelayError",
+ message="Job_no 1019905 is invalid. Job not found.",
+ )
+
+
+# --- abandon_job: rejection (the shared failure envelope) ---
+
+
+def test_abandon_job_returns_rejection_from_the_shared_failure_envelope(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_canceljob_relayerror_response.xml"
+ )
+
+ # Act
+ result = client.abandon_job(_spec_example_abandon_request())
+
+ # Assert
+ assert result == AbriRequestRejected(
+ code="RelayError",
+ message="Job_no 1019905 is invalid. Job not found.",
+ )
+
+
+# --- abandon_job: ambiguous responses are retriable, never success or rejection ---
+
+
+def test_abandon_job_raises_retriable_parse_error_on_a_malformed_response_body(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = b"not xml at all <<<"
+
+ # Act / Assert
+ with pytest.raises(AbriResponseParseError):
+ client.abandon_job(_spec_example_abandon_request())
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ # A cancellation collection with no JobCancelled and no ErrorDetails:
+ # genuinely ambiguous, so a dead job never churns as success/rejection.
+ b"",
+ b"",
+ # JobCancelled present but missing its job_no.
+ b"",
+ # ErrorDetails present but missing code or message.
+ b'',
+ b'',
+ # The shared envelope, but not a failure document.
+ b"true",
+ b"",
+ ],
+)
+def test_abandon_job_raises_retriable_parse_error_on_an_unexpectedly_shaped_response(
+ client: AbriClient, mock_session: MagicMock, body: bytes
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = body
+
+ # Act / Assert
+ with pytest.raises(AbriResponseParseError):
+ client.abandon_job(_spec_example_abandon_request())
+
+
+# --- abandon_job: transport failures ---
+
+
+def test_abandon_job_raises_transport_error_on_http_error_status(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = b"Server Error"
+ mock_session.post.return_value.raise_for_status.side_effect = requests.HTTPError(
+ "500 Server Error"
+ )
+
+ # Act / Assert
+ with pytest.raises(AbriTransportError):
+ client.abandon_job(_spec_example_abandon_request())
+
+
+def test_abandon_job_raises_transport_error_when_the_relay_is_unreachable(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.side_effect = requests.ConnectionError("connection refused")
+
+ # Act / Assert
+ with pytest.raises(AbriTransportError):
+ client.abandon_job(_spec_example_abandon_request())
+
+
# --- log_job: rejection ---
@@ -348,3 +472,42 @@ def test_amend_job_raises_transport_error_when_the_relay_is_unreachable(
# Act / Assert
with pytest.raises(AbriTransportError):
client.amend_job(_spec_example_amend_request())
+
+
+# --- abandon_job: success ---
+
+
+def test_abandon_job_returns_job_abandoned_with_the_cancelled_job_number(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_canceljob_success_response.xml"
+ )
+
+ # Act
+ result = client.abandon_job(_spec_example_abandon_request())
+
+ # Assert
+ assert result == JobAbandoned(job_no="AC0439951")
+
+
+def test_abandon_job_sends_the_recorded_canceljob_envelope_to_the_relay_endpoint(
+ client: AbriClient, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_canceljob_success_response.xml"
+ )
+
+ # Act
+ client.abandon_job(_spec_example_abandon_request())
+
+ # Assert
+ (url,) = mock_session.post.call_args.args
+ sent_body: bytes = mock_session.post.call_args.kwargs["data"]
+ expected_body = _load_fixture("abri_relay_canceljob_request_example.xml")
+ assert url == ENDPOINT_URL
+ assert ET.canonicalize(xml_data=sent_body, strip_text=True) == ET.canonicalize(
+ xml_data=expected_body, strip_text=True
+ )
diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py
new file mode 100644
index 000000000..6243bcb9f
--- /dev/null
+++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py
@@ -0,0 +1,232 @@
+import xml.etree.ElementTree as ET
+from datetime import date
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from domain.abri.models import AbriRequestRejected, JobAbandoned
+from infrastructure.abri.abri_client import AbriClient
+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,
+)
+
+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"
+CONFIRMED_SURVEY_DATE = date(2026, 6, 24)
+LAST_SUBMISSION_DATE = date(2026, 7, 1)
+
+CONFIG = AbriConfig(
+ endpoint_url=ENDPOINT_URL,
+ username="DomnaWeb",
+ password="",
+ default_resource="NAULKH",
+)
+
+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:
+ return (FIXTURE_DIR / name).read_bytes()
+
+
+class FakeDealDatabase:
+ """In-memory stand-in for the deal-database gateway."""
+
+ def __init__(self) -> None:
+ self.recorded_job_nos: List[Tuple[str, str]] = []
+ self.job_nos: Dict[str, str] = {}
+
+ def record_job_no(self, deal_id: str, job_no: str) -> None:
+ self.recorded_job_nos.append((deal_id, job_no))
+ self.job_nos[deal_id] = job_no
+
+ def job_no_for_deal(self, deal_id: str) -> Optional[str]:
+ return self.job_nos.get(deal_id)
+
+
+@pytest.fixture()
+def mock_session() -> MagicMock:
+ return MagicMock()
+
+
+@pytest.fixture()
+def deal_database() -> FakeDealDatabase:
+ return FakeDealDatabase()
+
+
+@pytest.fixture()
+def orchestrator(
+ mock_session: MagicMock,
+ deal_database: FakeDealDatabase,
+) -> AbriOrchestrator:
+ sdk_client = MagicMock()
+ with patch(
+ "infrastructure.abri.abri_client.requests.Session",
+ return_value=mock_session,
+ ):
+ abri_client = AbriClient(config=CONFIG)
+ return AbriOrchestrator(
+ abri_client=abri_client,
+ deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client),
+ deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client),
+ deal_database=deal_database,
+ )
+
+
+# --- outbound request: canceljob dates the abandonment to the survey date ---
+
+
+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,
+) -> 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(ABANDONMENT)
+
+ # Assert
+ (url,) = mock_session.post.call_args.args
+ assert url == ENDPOINT_URL
+ assert _sent_parameters(mock_session) == {
+ "job_no": JOB_NO,
+ "abandon_reason": "CARDED",
+ "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 ---
+
+
+def test_abandon_job_returns_the_abandoned_job(
+ 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
+ result = orchestrator.abandon_job(ABANDONMENT)
+
+ # Assert
+ assert result == JobAbandoned(job_no=JOB_NO)
+
+
+# --- ordering race: abandonment before the job_no write-back has landed ---
+
+
+def test_abandon_job_raises_a_retriable_error_when_no_job_no_has_been_recorded(
+ orchestrator: AbriOrchestrator,
+ mock_session: MagicMock,
+) -> None:
+ # Act / Assert
+ with pytest.raises(JobNoNotYetRecordedError):
+ orchestrator.abandon_job(ABANDONMENT)
+ assert mock_session.post.call_count == 0
+
+
+# --- rejection passthrough: OpenHousing's actual reason, verbatim ---
+
+
+def test_abandon_job_returns_the_openhousing_rejection_verbatim(
+ 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_errordetails_response.xml"
+ )
+
+ # Act
+ result = orchestrator.abandon_job(ABANDONMENT)
+
+ # Assert
+ assert isinstance(result, AbriRequestRejected)
+ assert result.code == "RelayError"
+ assert result.message.startswith("Job_no 1019905 is invalid")