Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/accounts-ensure-and-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,35 @@ Call `await client.accounts.refresh()` from a cron job or an `account_request.ch

## Separate accounts

`client.accounts.change(...)` with `allow_separate_account`, and `client.accounts.use(...)`, arrive in a later beta. A seller opening a separate account never re-points the registry on its own.
```python
outcome = await client.accounts.change(
"acc_nova", {"brand": {"domain": "nova-athletics.example", "countries": ["BE", "DE", "NL"]}},
allow_separate_account=True)

match outcome:
case AccountUpdated(account_id=aid, revision=rev): ... # same account, new revision
case AccountSeparate(account_id=new, previous_account_id=old) as separate:
# Nothing moved. separate.not_carried_over and separate.subscriptions_not_covering
# say what the new account lacks. New buys go to it only after:
await client.accounts.use(separate.key, new)
case AccountMatched(account_id=other): ... # an existing account matched
case AccountPending(action=action): ...
case AccountRejected() as rejected if rejected.requires_new_account: ...
```

- **Default.** `allow_separate_account` defaults to `False`, as in the spec. A seller that would accept the change only as a separate account rejects it, and `outcome.requires_new_account` is `True` (`ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`).
- **Pending changes.** A pending change is not kept in the registry. Keep its `request_id` and read it back with `await client.accounts.change_status(account_id, request_id)`; a new change request supersedes an open one.
- **No silent re-point.** `change()` never re-points the registry; `use()` records the account with its own status from `list_accounts`, so the spend gate still holds a `payment_required` or pending account. After `AccountSeparate` or `AccountMatched`, the natural key keeps naming the old account until you call `client.accounts.use(key, account_id)`. Which account carries new buys stays your explicit choice.
- **Not carried over.** `AccountSeparate.not_carried_over` lists the account-level configurations the new account lacks. `subscriptions_not_covering` lists the `account_ids`-scoped reporting subscriptions that will not see it.
- **Next step.** `next_step(outcome)` is `none` for `AccountUpdated`, `AccountSeparate` and `AccountMatched`.

## Money changes and revision

A change to `billing`, `billing_entity` or `payment_terms` needs the explicit account `revision` it was approved against. Send it with `request_account` until `change()` lands.
A change to `billing`, `billing_entity` or `payment_terms` needs the explicit account `revision` it was approved against: `await client.accounts.change(account_id, {"payment_terms": "net_60"}, revision=3)`.
- **Without `revision`** the call is refused before anything is sent: `AccountRequestError` with `dispatched=False` and code `ACCOUNT_REVISION_REQUIRED`. The SDK never reads the revision for you, because a human approved the change against a specific revision, and reading it would defeat the lost-update protection.
- **`effective_at`.** `AccountUpdated.effective_at` says when the new money terms take effect. Delivery before it is invoiced under the previous terms.
- **Other changes** (`brand` countries, `operator_unit`, `currency`, `timezone`) read the current `revision` from `list_accounts` when you omit it. On `CONFLICT` they retry once, but only when the fields being changed did not change concurrently.
- **Sellers without `account.requests`.** `change()` is refused (`ACCOUNT_REQUESTS_UNSUPPORTED`); change accounts through `sync_accounts` instead.

## Cold start

Expand Down
12 changes: 12 additions & 0 deletions docs/reporting-subscriptions-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,15 @@ Every SDK error carries `code` and `recovery` (`transient`, `correctable` or `te
| `SUBSCRIPTION_ROSTER_UNAVAILABLE` | `ReliableReportingConfigurationError` | n/a | correctable | serve `list_accounts`, or supply `SubscriptionOptions.accounts` |

`AccountRequestError` is raised only when `client.accounts.ensure` could not be processed; it carries the wire codes. Account outcomes are values.

## Accounts

`client.accounts.change()` refuses locally, before anything is sent, with `AccountRequestError` (`dispatched=False`, `recovery="correctable"`):

| Code | Cause | Fix |
|---|---|---|
| `ACCOUNT_REVISION_REQUIRED` | a change to `billing`, `billing_entity` or `payment_terms` without `revision` | pass the revision the change was approved against, read from `list_accounts` |
| `ACCOUNT_CHANGE_INVALID` | no attribute, or one `change()` cannot change | change `brand`, `operator_unit`, `currency`, `timezone` or a money term |
| `ACCOUNT_REQUESTS_UNSUPPORTED` | the seller does not advertise `account.requests` | change the account through `sync_accounts` |

A seller rejection is a value: `AccountRejected`. Its `requires_new_account` is `True` for `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`; resend with `allow_separate_account=True`. A `CONFLICT` on a money change is raised as `AccountRequestError`, because the human must approve the change against the new revision.
12 changes: 12 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ def _resolve_version() -> str:
),
"adcp.account_outcomes": (
"AccountAmbiguous",
"AccountChangeOutcome",
"AccountMatched",
"AccountSeparate",
"AccountUpdated",
"AccountOutcome",
"AccountPending",
"AccountReady",
Expand Down Expand Up @@ -1049,6 +1053,10 @@ def get_adcp_version() -> str:
"InMemoryAccountStorage",
"ListableAccountStorage",
"AccountAmbiguous",
"AccountChangeOutcome",
"AccountMatched",
"AccountSeparate",
"AccountUpdated",
"AccountOutcome",
"AccountPending",
"AccountReady",
Expand Down Expand Up @@ -1843,10 +1851,14 @@ def get_adcp_version() -> str:
)
from adcp.account_outcomes import (
AccountAmbiguous,
AccountChangeOutcome,
AccountMatched,
AccountOutcome,
AccountPending,
AccountReady,
AccountRejected,
AccountSeparate,
AccountUpdated,
PendingAction,
format_pending_action,
next_step,
Expand Down
81 changes: 79 additions & 2 deletions src/adcp/account_outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
from typing import Any, Literal

__all__ = [
"AccountChangeOutcome",
"AccountMatched",
"AccountSeparate",
"AccountUpdated",
"AccountAmbiguous",
"AccountOutcome",
"AccountPending",
Expand Down Expand Up @@ -181,11 +185,84 @@ class AccountRejected:
request_id: str | None = None
kind: Literal["rejected"] = "rejected"

@property
def requires_new_account(self) -> bool:
"""``ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT``: the seller would accept the change only
as a separate account; resend with ``allow_separate_account=True``."""
return any(
str(item.get("code")) == "ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT" for item in self.errors
)


AccountOutcome = AccountReady | AccountPending | AccountAmbiguous | AccountRejected


def next_step(outcome: AccountOutcome) -> NextStep:
@dataclass(frozen=True)
class AccountUpdated:
"""``client.accounts.change``: the named account changed in place.

``account_id`` is unchanged and ``revision`` is the account's new revision.
``effective_at`` is when changed money terms (``billing``,
``billing_entity``, ``payment_terms``) take effect: delivery before it is
invoiced under the previous terms (AdCP 3.3.0-beta.3).
"""

account_id: str
revision: int | None = None
effective_at: str | None = None
account: Mapping[str, Any] | None = None
request_id: str | None = None
via: AccountVia = "request_account"
kind: Literal["updated"] = "updated"


@dataclass(frozen=True)
class AccountSeparate:
"""``client.accounts.change``: the seller opened a separate account with the change.

Nothing moved: ``previous_account_id`` is unchanged, and the registry
still maps the natural ``key`` to it until you call
``client.accounts.use(key, account_id)``. ``not_carried_over`` names the
account-level configurations the new account does not have, and
``subscriptions_not_covering`` the ``account_ids``-scoped reporting
subscriptions that will not see it.
"""

account_id: str
previous_account_id: str
key: Mapping[str, Any] | None = None
account: Mapping[str, Any] | None = None
not_carried_over: tuple[str, ...] = ()
subscriptions_not_covering: tuple[str, ...] = ()
request_id: str | None = None
via: AccountVia = "request_account"
kind: Literal["separate_account"] = "separate_account"


@dataclass(frozen=True)
class AccountMatched:
"""``client.accounts.change``: the change matched another existing account.

As with :class:`AccountSeparate`, the registry is not re-pointed until
``client.accounts.use(key, account_id)``. Read the matched account's money
terms from :attr:`account` before spending.
"""

account_id: str
previous_account_id: str
key: Mapping[str, Any] | None = None
account: Mapping[str, Any] | None = None
request_id: str | None = None
via: AccountVia = "request_account"
kind: Literal["matched_existing"] = "matched_existing"


AccountChangeOutcome = (
AccountUpdated | AccountSeparate | AccountMatched | AccountPending | AccountRejected
)


def next_step(outcome: AccountOutcome | AccountChangeOutcome) -> NextStep:
"""What to do next, independent of the extensible ``kind`` vocabulary.

* ``none``: the account is ready.
Expand All @@ -195,7 +272,7 @@ def next_step(outcome: AccountOutcome) -> NextStep:
* ``pick_candidate``: choose one of ``candidates``.
* ``contact_seller``: rejected, or a buyer action without a URL.
"""
if isinstance(outcome, AccountReady):
if isinstance(outcome, (AccountReady, AccountUpdated, AccountSeparate, AccountMatched)):
return "none"
if isinstance(outcome, AccountAmbiguous):
return "pick_candidate"
Expand Down
Loading
Loading