diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index ed4c31501..769b6276b 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -1,14 +1,16 @@ """DecisioningPlatform impl for the v3 reference seller. -Sales-non-guaranteed specialism with the five required Sales methods: +Sales-non-guaranteed specialism with seven implemented methods: * :meth:`get_products` — read inventory catalog * :meth:`create_media_buy` — terminal artifact insert; idempotency-keyed * :meth:`update_media_buy` — patch (status / pause / spend cap) * :meth:`sync_creatives` — accept creative manifests * :meth:`get_media_buy_delivery` — read delivery actuals +* :meth:`sync_accounts` — upsert accounts; billing_entity stored with bank, stripped on response +* :meth:`list_accounts` — list accounts projected through the write-only guard -All five run against the SQLAlchemy models in :mod:`models`. The +All seven run against the SQLAlchemy models in :mod:`models`. The platform reads the resolved :class:`adcp.decisioning.BuyerAgent` from :attr:`RequestContext.buyer_agent` (set by the framework's dispatch gate) and the :class:`adcp.decisioning.Account` from @@ -36,16 +38,24 @@ DecisioningCapabilities, DecisioningPlatform, ExplicitAccounts, + project_account_for_response, + project_business_entity_for_response, ) from adcp.decisioning.specialisms import SalesPlatform +from adcp.server import current_tenant from adcp.types import ( + Account as AdcpAccount, CreateMediaBuyRequest, CreateMediaBuySuccessResponse, GetMediaBuyDeliveryRequest, GetMediaBuyDeliveryResponse, GetProductsRequest, GetProductsResponse, + ListAccountsRequest, + ListAccountsResponse, Product, + SyncAccountsRequest, + SyncAccountsSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, UpdateMediaBuyRequest, @@ -58,6 +68,7 @@ from adcp.decisioning import RequestContext from .models import Account as AccountRow +from .models import BuyerAgent as BuyerAgentRow from .models import MediaBuy as MediaBuyRow logger = logging.getLogger(__name__) @@ -311,6 +322,220 @@ async def get_media_buy_delivery( del req, ctx return GetMediaBuyDeliveryResponse(media_buys=[]) + # ----- sync_accounts --------------------------------------------------- + + async def sync_accounts( + self, req: SyncAccountsRequest, ctx: RequestContext + ) -> SyncAccountsSuccessResponse: + """Upsert advertiser accounts and return results with bank details stripped. + + Persists the full :attr:`billing_entity` (including bank coordinates) to the + ``accounts`` table for invoicing, then echoes it back with ``bank`` cleared — + the AdCP 3.1 write-only guard. Production adopters add approval workflows, + credit-limit checks, and dry_run rollback here. + + Note: ``dry_run`` is intentionally ignored in this reference impl — the + session always commits. Production adopters should branch on ``req.dry_run`` + before ``session.begin()`` and call ``await session.rollback()`` (or avoid + ``session.begin()`` entirely) when it is ``True``. + """ + if ctx.buyer_agent is None: + raise AdcpError( + "INTERNAL_ERROR", + message="Dispatch should have populated buyer_agent.", + recovery="terminal", + ) + tenant = current_tenant() + if tenant is None: + raise AdcpError( + "AUTH_INVALID", + message="sync_accounts called without a tenant context.", + recovery="terminal", + ) + + results: list[dict[str, Any]] = [] + async with self._sessionmaker() as session, session.begin(): + # ctx.buyer_agent.ext doesn't carry the DB surrogate id — _row_to_agent + # in buyer_registry.py discards row.id. Re-query to get the FK. + ba_result = await session.execute( + select(BuyerAgentRow).where( + BuyerAgentRow.tenant_id == tenant.id, + BuyerAgentRow.agent_url == ctx.buyer_agent.agent_url, + ) + ) + ba_row = ba_result.scalar_one_or_none() + if ba_row is None: + raise AdcpError( + "INTERNAL_ERROR", + message=( + f"BuyerAgent row for {ctx.buyer_agent.agent_url!r} " + f"not found under tenant {tenant.id!r}." + ), + recovery="terminal", + ) + + for acct in req.accounts: + # Natural upsert key — brand.domain and operator both forbid colons + # (domain-pattern validation), so the colon separator is safe. + # Adopters with an existing account-ID scheme should replace this + # derivation with their own stable key (e.g. a UUID assigned on + # first creation and stored in a separate column). + wire_acct_id = f"{acct.brand.domain}:{acct.operator}" + + acct_result = await session.execute( + select(AccountRow).where( + AccountRow.tenant_id == tenant.id, + AccountRow.buyer_agent_id == ba_row.id, + AccountRow.account_id == wire_acct_id, + ) + ) + row = acct_result.scalar_one_or_none() + + billing_json = ( + acct.billing_entity.model_dump(mode="json", exclude_none=True) + if acct.billing_entity + else None + ) + # Prefer the legal entity name for the human-readable account label; + # fall back to the brand domain when billing_entity is absent. + acct_name = ( + acct.billing_entity.legal_name + if acct.billing_entity and acct.billing_entity.legal_name + else acct.brand.domain + ) + # Normalise None → False for the NOT NULL column; preserve the + # normalised value in the response so DB and wire stay consistent. + sandbox_val = acct.sandbox if acct.sandbox is not None else False + + if row is None: + action = "created" + row = AccountRow( + tenant_id=tenant.id, + buyer_agent_id=ba_row.id, + account_id=wire_acct_id, + name=acct_name, + status="active", + billing=acct.billing.value if acct.billing else None, + billing_entity=billing_json, + sandbox=sandbox_val, + ) + session.add(row) + status_str = "active" + else: + action = "updated" + row.billing_entity = billing_json + row.name = acct_name + if acct.billing: + row.billing = acct.billing.value + row.sandbox = sandbox_val + row.updated_at = datetime.now(timezone.utc) + status_str = row.status + + # Strip write-only bank details before echoing in the response. + # Entity-level helper used here because acct.billing_entity is a + # raw BusinessEntity from the wire — not a full stored Account. + # Use project_account_for_response when you have an Account object + # from storage (as in list_accounts below). + safe_be = ( + project_business_entity_for_response(acct.billing_entity) + if acct.billing_entity is not None + else None + ) + results.append( + { + "account_id": wire_acct_id, + "name": acct_name, + "brand": acct.brand.model_dump(mode="json"), + "operator": acct.operator, + "action": action, + "status": status_str, + "billing": acct.billing.value if acct.billing else None, + "billing_entity": ( + safe_be.model_dump(mode="json", exclude_none=True) + if safe_be is not None + else None + ), + "sandbox": sandbox_val, + } + ) + + # dry_run is intentionally always False here — we always commit. + # Echoing req.dry_run=True would lie to the caller about what was persisted. + return SyncAccountsSuccessResponse(accounts=results, dry_run=False) + + # ----- list_accounts --------------------------------------------------- + + async def list_accounts( + self, req: ListAccountsRequest, ctx: RequestContext + ) -> ListAccountsResponse: + """List accounts for the authenticated agent, bank details stripped (write-only). + + Applies optional ``status`` and ``sandbox`` filters from the request. + Production adopters add pagination, ownership scoping, and field-level + access control here. + """ + if ctx.buyer_agent is None: + raise AdcpError( + "INTERNAL_ERROR", + message="Dispatch should have populated buyer_agent.", + recovery="terminal", + ) + tenant = current_tenant() + if tenant is None: + raise AdcpError( + "AUTH_INVALID", + message="list_accounts called without a tenant context.", + recovery="terminal", + ) + + async with self._sessionmaker() as session: + # ctx.buyer_agent.ext doesn't carry the DB surrogate id — re-query. + ba_result = await session.execute( + select(BuyerAgentRow).where( + BuyerAgentRow.tenant_id == tenant.id, + BuyerAgentRow.agent_url == ctx.buyer_agent.agent_url, + ) + ) + ba_row = ba_result.scalar_one_or_none() + if ba_row is None: + return ListAccountsResponse(accounts=[]) + + query = select(AccountRow).where( + AccountRow.tenant_id == tenant.id, + AccountRow.buyer_agent_id == ba_row.id, + ) + if req.status is not None: + query = query.where(AccountRow.status == req.status.value) + if req.sandbox is not None: + query = query.where(AccountRow.sandbox == req.sandbox) + + acct_result = await session.execute(query) + rows = list(acct_result.scalars().all()) + + accounts = [] + for row in rows: + # model_validate coerces the billing_entity JSON dict → BusinessEntity + # so project_account_for_response receives a real Pydantic object and + # can call model_copy() safely. + wire_acct = AdcpAccount.model_validate( + { + "account_id": row.account_id, + "name": row.name, + "status": row.status, + "billing": row.billing, + "billing_entity": row.billing_entity, + "rate_card": row.rate_card, + "payment_terms": row.payment_terms, + "credit_limit": row.credit_limit, + "sandbox": row.sandbox, + "ext": row.ext, + "reporting_bucket": row.reporting_bucket, + } + ) + accounts.append(project_account_for_response(wire_acct)) + + return ListAccountsResponse(accounts=accounts) + def _project_start_time(value: Any) -> datetime: """Project :class:`StartTiming` (root: ``'asap'`` | :class:`AwareDatetime`) diff --git a/examples/v3_reference_seller/tests/test_smoke.py b/examples/v3_reference_seller/tests/test_smoke.py index 2cd59fa43..85a3a8bf0 100644 --- a/examples/v3_reference_seller/tests/test_smoke.py +++ b/examples/v3_reference_seller/tests/test_smoke.py @@ -96,6 +96,197 @@ def scalar_one_or_none(self): assert result is None +@pytest.mark.asyncio +async def test_sync_accounts_strips_bank_details() -> None: + """sync_accounts must NOT echo bank details in its response (write-only guard) + and MUST preserve non-write-only fields such as legal_name.""" + from unittest.mock import MagicMock, patch + + from src.platform import V3ReferenceSeller + + from adcp.decisioning import BuyerAgent, RequestContext + from adcp.types import SyncAccountsRequest + + # --- stub DB session --------------------------------------------------- + mock_ba_row = MagicMock() + mock_ba_row.id = "ba_stub123" + + class _StubSession: + """Returns BuyerAgent row on first execute, no existing account on second.""" + + def __init__(self) -> None: + self._calls = 0 + + async def __aenter__(self) -> _StubSession: + return self + + async def __aexit__(self, *args: object) -> None: + pass + + def begin(self) -> _BeginCM: + return _BeginCM() + + async def execute(self, _stmt: object) -> MagicMock: + self._calls += 1 + result = MagicMock() + if self._calls == 1: + result.scalar_one_or_none.return_value = mock_ba_row + else: + result.scalar_one_or_none.return_value = None # new account + return result + + def add(self, _row: object) -> None: + pass + + class _BeginCM: + async def __aenter__(self) -> _BeginCM: + return self + + async def __aexit__(self, *args: object) -> None: + pass + + sessionmaker = MagicMock(return_value=_StubSession()) + + seller = V3ReferenceSeller(sessionmaker=sessionmaker) + + req = SyncAccountsRequest.model_validate( + { + "idempotency_key": "smoke-test-sync-key-abc1234567", + "accounts": [ + { + "brand": {"domain": "acme.com"}, + "operator": "agency.com", + "billing": "operator", + "billing_entity": { + "legal_name": "Acme Corp", + "address": { + "street": "123 Main St", + "city": "Springfield", + "postal_code": "62701", + "country": "US", + }, + "bank": { + "account_holder": "Acme Corp", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2LXXX", + }, + }, + } + ], + } + ) + + fake_tenant = MagicMock() + fake_tenant.id = "t_smoke123" + buyer_agent = BuyerAgent( + agent_url="https://buyer.example.com/", + display_name="Test Buyer", + status="active", + billing_capabilities=frozenset(["operator"]), + ) + ctx = RequestContext(buyer_agent=buyer_agent) + + with patch("src.platform.current_tenant", return_value=fake_tenant): + response = await seller.sync_accounts(req, ctx) + + payload = response.model_dump(mode="json", exclude_none=True) + assert payload["accounts"], "Expected at least one account result" + for acct_result in payload["accounts"]: + be = acct_result.get("billing_entity") + assert be is not None, "billing_entity must be echoed in response" + assert "bank" not in be, "bank details (write-only) must not appear in response" + assert be.get("legal_name") == "Acme Corp", "legal_name must be preserved" + + +@pytest.mark.asyncio +async def test_list_accounts_strips_bank_details() -> None: + """list_accounts must project billing_entity through the write-only guard — + bank absent, other fields preserved.""" + from unittest.mock import MagicMock, patch + + from src.platform import V3ReferenceSeller + + from adcp.decisioning import BuyerAgent, RequestContext + from adcp.types import ListAccountsRequest + + mock_ba_row = MagicMock() + mock_ba_row.id = "ba_stub456" + + # Synthetic AccountRow with bank details stored in billing_entity + mock_acct_row = MagicMock() + mock_acct_row.account_id = "acme.com:agency.com" + mock_acct_row.name = "Acme Corp" + mock_acct_row.status = "active" + mock_acct_row.billing = "operator" + mock_acct_row.billing_entity = { + "legal_name": "Acme Corp", + "address": { + "street": "123 Main St", + "city": "Springfield", + "postal_code": "62701", + "country": "US", + }, + "bank": { + "account_holder": "Acme Corp", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2LXXX", + }, + } + mock_acct_row.rate_card = None + mock_acct_row.payment_terms = None + mock_acct_row.credit_limit = None + mock_acct_row.sandbox = False + mock_acct_row.ext = None + mock_acct_row.reporting_bucket = None + + class _StubSession: + def __init__(self) -> None: + self._calls = 0 + + async def __aenter__(self) -> _StubSession: + return self + + async def __aexit__(self, *args: object) -> None: + pass + + async def execute(self, _stmt: object) -> MagicMock: + self._calls += 1 + result = MagicMock() + if self._calls == 1: + result.scalar_one_or_none.return_value = mock_ba_row + else: + scalars_mock = MagicMock() + scalars_mock.all.return_value = [mock_acct_row] + result.scalars.return_value = scalars_mock + return result + + sessionmaker = MagicMock(return_value=_StubSession()) + + seller = V3ReferenceSeller(sessionmaker=sessionmaker) + req = ListAccountsRequest.model_validate({}) + + fake_tenant = MagicMock() + fake_tenant.id = "t_smoke456" + buyer_agent = BuyerAgent( + agent_url="https://buyer.example.com/", + display_name="Test Buyer", + status="active", + billing_capabilities=frozenset(["operator"]), + ) + ctx = RequestContext(buyer_agent=buyer_agent) + + with patch("src.platform.current_tenant", return_value=fake_tenant): + response = await seller.list_accounts(req, ctx) + + payload = response.model_dump(mode="json", exclude_none=True) + assert payload["accounts"], "Expected at least one account" + for acct in payload["accounts"]: + be = acct.get("billing_entity") + assert be is not None, "billing_entity must be present" + assert "bank" not in be, "bank details (write-only) must not appear in response" + assert be.get("legal_name") == "Acme Corp", "legal_name must be preserved" + + @pytest.mark.asyncio async def test_buyer_registry_returns_none_without_tenant() -> None: """Without a tenant context (ContextVar unset), the registry