diff --git a/domain/abri/models.py b/domain/abri/models.py
index d96d27fda..159d184f2 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 (needs Abri's code list).
+ """
+
+ 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/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py
index 80f9cde77..3b85903d0 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,9 @@ class AbriClient:
return self._parse_appointment_amended(outcome)
+ def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult:
+ raise NotImplementedError
+
def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
outcome = self._exchange(
request_type="getSCSTenantData",
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/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py
index 6ab6c7698..5def9f129 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()
@@ -348,3 +359,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
+ )