diff --git a/domain/abri/models.py b/domain/abri/models.py
index 0444ec1cc..ceb03c34f 100644
--- a/domain/abri/models.py
+++ b/domain/abri/models.py
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from datetime import date
-from typing import Literal, NewType, Optional, Union
+from typing import Literal, NewType, Optional, Tuple, Union
PlaceRef = NewType("PlaceRef", str)
@@ -48,3 +48,31 @@ class AppointmentAmended:
AmendJobResult = Union[AppointmentAmended, AbriRequestRejected]
+
+
+@dataclass(frozen=True)
+class Tenant:
+ """A tenancy signatory, minimised to the fields the HubSpot sync needs.
+
+ Person title, per-person references, the main-contact flag and tenancy
+ dates are deliberately dropped at parse time (data minimisation); mobile
+ and telephone are the single best number of each kind, pre-selected by
+ priority.
+ """
+
+ forenames: str
+ surname: str
+ mobile: Optional[str]
+ telephone: Optional[str]
+ vulnerabilities: Tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class TenancyData:
+ """Tenancy signatories for a place; the reference is PII-safe."""
+
+ tenancy_reference: str
+ tenants: Tuple[Tenant, ...]
+
+
+GetTenantDataResult = Union[TenancyData, AbriRequestRejected]
diff --git a/etl/hubspot/deal_contacts_client.py b/etl/hubspot/deal_contacts_client.py
new file mode 100644
index 000000000..df8a8f0ec
--- /dev/null
+++ b/etl/hubspot/deal_contacts_client.py
@@ -0,0 +1,25 @@
+from typing import Dict
+
+from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
+
+# HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms).
+DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3
+
+
+class HubspotDealContactsClient:
+ """Focused HubSpot client for the tenant-data sync flow.
+
+ Deliberately separate from the legacy HubspotClient: the SDK client is
+ constructor-injected, and SDK errors are re-raised scrubbed of response
+ bodies (HubSpot validation errors echo submitted property values, which
+ here are tenant PII).
+ """
+
+ def __init__(self, sdk_client: Client) -> None:
+ self._client: Client = sdk_client
+
+ def create_contact(self, properties: Dict[str, str]) -> str:
+ raise NotImplementedError
+
+ def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None:
+ raise NotImplementedError
diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py
index 3c95c2766..6c9cc883d 100644
--- a/infrastructure/abri/abri_client.py
+++ b/infrastructure/abri/abri_client.py
@@ -9,9 +9,11 @@ from domain.abri.models import (
AmendJobRequest,
AmendJobResult,
AppointmentAmended,
+ GetTenantDataResult,
JobLogged,
LogJobRequest,
LogJobResult,
+ PlaceRef,
)
from infrastructure.abri.config import AbriConfig
from infrastructure.abri.envelope import serialise_relay_request
@@ -73,6 +75,9 @@ class AbriClient:
return self._parse_appointment_amended(outcome)
+ def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
+ raise NotImplementedError
+
def _exchange(
self, request_type: str, parameters: List[Tuple[str, str]]
) -> Union[ET.Element, AbriRequestRejected]:
diff --git a/orchestration/tenant_data_sync_orchestrator.py b/orchestration/tenant_data_sync_orchestrator.py
new file mode 100644
index 000000000..18790f9d1
--- /dev/null
+++ b/orchestration/tenant_data_sync_orchestrator.py
@@ -0,0 +1,37 @@
+from dataclasses import dataclass
+from typing import Tuple, Union
+
+from domain.abri.models import AbriRequestRejected, PlaceRef
+from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
+from infrastructure.abri.abri_client import AbriClient
+
+
+@dataclass(frozen=True)
+class TenantDataSyncSummary:
+ """PII-free record of what a sync run did: safe to log and persist."""
+
+ tenancy_reference: str
+ contact_ids: Tuple[str, ...]
+ vulnerable_contact_count: int
+
+
+TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected]
+
+
+class TenantDataSyncOrchestrator:
+ """Syncs an Abri tenancy's signatories to a HubSpot deal.
+
+ Tenant data flows straight from Abri to HubSpot; nothing is persisted
+ in Domna's database or logs.
+ """
+
+ def __init__(
+ self,
+ abri_client: AbriClient,
+ hubspot: HubspotDealContactsClient,
+ ) -> None:
+ self._abri = abri_client
+ self._hubspot = hubspot
+
+ def run(self, place_ref: PlaceRef, deal_id: str) -> TenantDataSyncResult:
+ raise NotImplementedError
diff --git a/tests/orchestration/test_tenant_data_sync_orchestrator.py b/tests/orchestration/test_tenant_data_sync_orchestrator.py
new file mode 100644
index 000000000..89e188b35
--- /dev/null
+++ b/tests/orchestration/test_tenant_data_sync_orchestrator.py
@@ -0,0 +1,109 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs]
+from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs]
+
+from domain.abri.models import PlaceRef
+from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
+from infrastructure.abri.abri_client import AbriClient
+from infrastructure.abri.config import AbriConfig
+from orchestration.tenant_data_sync_orchestrator import (
+ TenantDataSyncOrchestrator,
+ TenantDataSyncSummary,
+)
+
+ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key"
+PLACE_REF = PlaceRef("1004202A")
+DEAL_ID = "9876543210"
+
+CONFIG = AbriConfig(
+ endpoint_url=ENDPOINT_URL,
+ username="DomnaWeb",
+ password="",
+ default_resource="NAULKH",
+)
+
+
+def _tenancy_response(
+ tenants_xml: str, tenancy_reference: str = "10042020051017"
+) -> bytes:
+ return (
+ f''
+ f"{tenancy_reference}{tenants_xml}"
+ ).encode()
+
+
+@pytest.fixture()
+def mock_session() -> MagicMock:
+ return MagicMock()
+
+
+@pytest.fixture()
+def sdk_client() -> MagicMock:
+ sdk = MagicMock()
+ sdk.crm.contacts.basic_api.create.return_value.id = "60123"
+ return sdk
+
+
+@pytest.fixture()
+def orchestrator(
+ mock_session: MagicMock, sdk_client: MagicMock
+) -> TenantDataSyncOrchestrator:
+ with patch(
+ "infrastructure.abri.abri_client.requests.Session",
+ return_value=mock_session,
+ ):
+ abri_client = AbriClient(config=CONFIG)
+ return TenantDataSyncOrchestrator(
+ abri_client=abri_client,
+ hubspot=HubspotDealContactsClient(sdk_client=sdk_client),
+ )
+
+
+# --- happy path: signatories become associated deal contacts ---
+
+
+def test_run_creates_an_associated_deal_contact_per_signatory_and_returns_a_summary(
+ orchestrator: TenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '136847309853460810'
+ '02473757484'
+ )
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert result == TenantDataSyncSummary(
+ tenancy_reference="10042020051017",
+ contact_ids=("60123",),
+ vulnerable_contact_count=0,
+ )
+ sdk_client.crm.contacts.basic_api.create.assert_called_once_with(
+ simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
+ properties={
+ "firstname": "Amanjeet",
+ "lastname": "Okello",
+ "mobilephone": "09853460810",
+ "phone": "02473757484",
+ "is_vulnerable": "false",
+ }
+ )
+ )
+ sdk_client.crm.associations.v4.basic_api.create.assert_called_once_with(
+ object_type="deals",
+ object_id=DEAL_ID,
+ to_object_type="contacts",
+ to_object_id="60123",
+ association_spec=[
+ AssociationSpec(
+ association_category="HUBSPOT_DEFINED", association_type_id=3
+ )
+ ],
+ )