diff --git a/.changeset/account-request-money-terms-and-timezone.md b/.changeset/account-request-money-terms-and-timezone.md new file mode 100644 index 00000000000..a46076a7010 --- /dev/null +++ b/.changeset/account-request-money-terms-and-timezone.md @@ -0,0 +1,10 @@ +--- +"adcontextprotocol": minor +--- + +Account requests (experimental): resolved requests carry money terms, money changes say when they take effect, and timezone changeability is consistent. + +- A request that resolves as `matched` or `created` now MUST carry `account`, with the account's `billing`, `billing_entity` (without write-only fields) and `payment_terms`, because they can differ from the terms requested. +- A request that resolves as `updated` and changed `billing`, `billing_entity` or `payment_terms` carries `effective_at`, when the new money terms take effect. +- A change request that resolves as `created` (a separate account) carries `not_carried_over`: the account-level configurations the new account doesn't have. +- `currency` and `timezone` change through `request_account` only on sellers that list them in `account.requests.changeable_attributes`; a seller that changes timezone MUST list it. `request_account` and `sync_principal` docs now agree. diff --git a/docs/accounts/tasks/request_account.mdx b/docs/accounts/tasks/request_account.mdx index 435785173e4..7aabc2e0a39 100644 --- a/docs/accounts/tasks/request_account.mdx +++ b/docs/accounts/tasks/request_account.mdx @@ -32,7 +32,7 @@ Call it only when [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities | `state` | Carries | What the buyer does | | --- | --- | --- | | `in_review` | `pending_action` | `waiting_on: seller`: wait for `account_request.changed` or poll [`list_accounts`](/docs/accounts/tasks/list_accounts) with `request_ids`. `waiting_on: buyer`: show `pending_action.url` to a human, then keep waiting. | -| `resolved` | `account_id`, `resolution` (`matched`, `created`, or `updated`), and for a change the updated `account` | Store `account_id` and send it on every account-scoped task. | +| `resolved` | `account_id`, `resolution` (`matched`, `created`, or `updated`), and `account`: always for `matched` and `created`, with the account's money terms, and for a change the updated account | Store `account_id` and send it on every account-scoped task. Check the money terms in `account`: they can differ from the ones requested. | | `ambiguous` | `candidates[]` | A human picks one; call `request_account` with that `account_id` and the open `request_id`, and the request resolves as `matched`. | | `rejected` | `errors[]` | Tell a human. A new request for the same key opens a new request. | @@ -74,7 +74,7 @@ Send either `account_id` (to change or select), or `brand` and `operator` (to op ## Changing an account -Send the `account_id` and only the attributes to change. The changeable attributes are those [#8474](https://github.com/adcontextprotocol/adcp/pull/8474) defines: the brand's `countries`, `operator_unit`, `billing`, `billing_entity`, and `payment_terms`. `currency` and `timezone` are set when the account opens, and a seller MAY refuse to change them; `sandbox` never changes. Set `allow_separate_account: true` if you would accept the change on a separate new account when the seller can't make it on this one; it defaults to `false`. +Send the `account_id` and only the attributes to change. The seller lists the attributes it changes in `account.requests.changeable_attributes`, from the brand's `countries`, `operator_unit`, `billing`, `billing_entity`, `payment_terms`, `currency`, and `timezone`. `currency` and `timezone` are set when the account opens and change only on a seller that lists them; anywhere else the change is refused with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). A timezone change is a reporting cutover: account-timezone periods switch at the first old-timezone boundary at or after it, as [reporting subscriptions](/docs/protocol/sync_principal#reporting-subscriptions) describe. `sandbox` never changes. Set `allow_separate_account: true` if you would accept the change on a separate new account when the seller can't make it on this one; it defaults to `false`. ```json { @@ -88,12 +88,12 @@ Send the `account_id` and only the attributes to change. The changeable attribut The seller validates a changed attribute as it does when the account opens, including authorization such as the brand-operator check. Then one of: -- **It applies the change:** `state: resolved`, `resolution: updated`, and `account` carrying the updated attributes and new `revision`. +- **It applies the change:** `state: resolved`, `resolution: updated`, and `account` carrying the updated attributes and new `revision`. When the change touches `billing`, `billing_entity`, or `payment_terms`, the request also carries `effective_at`, when the new money terms take effect: delivery before it is invoiced under the old terms. The seller may pick a later instant than resolution, such as its next invoice-period boundary. Other attributes change when the request resolves. - **It reviews the change first:** `state: in_review` with a pending action. The account keeps its current attributes until the request resolves. - **It refuses the change:** `state: rejected` with the reason. The account is unchanged. -- **It opens a separate account instead, if the buyer allowed it:** `state: resolved`, `resolution: created`, and a new `account_id`, with `account` carrying the new account. The seller says "yes, but as a separate account", for example when a new market needs its own contract or invoicing entity. The seller may do this only when the request set `allow_separate_account: true`. The new account has the named account's attributes with the requested change applied. Terms the seller needs to set differently go through a pending action before the request resolves. +- **It opens a separate account instead, if the buyer allowed it:** `state: resolved`, `resolution: created`, and a new `account_id`, with `account` carrying the new account. The seller says "yes, but as a separate account", for example when a new market needs its own contract or invoicing entity. The seller may do this only when the request set `allow_separate_account: true`. The new account has the named account's attributes with the requested change applied, and `account` carries its money terms. Terms the seller needs to set differently go through a pending action before the request resolves. The request's `not_carried_over` lists the named account's account-level configurations the new account doesn't have, from `notification_configs`, `reporting_delivery_configs`, `reporting_bucket`, `governance_agents`, `creatives`, `audiences`, `catalogs`, and `event_sources`; `[]` means all of them carried over. Principal-level configuration is unaffected: an `all_authorized_accounts` reporting subscription covers the new account once it is authorized, while an `account_ids` subscription covers it only after you add it. - **It would accept the change only as a separate account, and the buyer didn't allow that:** `state: rejected` with [`ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`](/docs/building/verification/compliance-catalog#error-code-account-change-requires-new-account). The account is unchanged. The buyer can tell this from a plain refusal: it may open a new account itself, resend the change with `allow_separate_account: true`, or keep the account as it is. -- **It points to an account the caller already holds:** `state: resolved`, `resolution: matched`, and that account's `account_id`. The seller uses this when the change would make the named account duplicate an account the caller can already reach; see [Accounts that end up alike](/docs/accounts/overview#account-identity). As with `candidates`, the seller MUST NOT point to an account outside the caller's grants. +- **It points to an account the caller already holds:** `state: resolved`, `resolution: matched`, and that account's `account_id`, with `account` carrying its money terms. The seller uses this when the change would make the named account duplicate an account the caller can already reach; see [Accounts that end up alike](/docs/accounts/overview#account-identity). As with `candidates`, the seller MUST NOT point to an account outside the caller's grants. When the request resolves to another account (`created` or `matched`), the named account is untouched: it keeps its attributes, media buys, and reporting history, and stays usable. The seller MUST NOT move media buys, creatives, or reporting between the two accounts. Which account carries new buys is the buyer's choice. A buyer can tell these outcomes apart because the resolved `account_id` differs from `requested.account_id`. @@ -148,14 +148,21 @@ A day later, sales ops finds no relationship and hands the next step to the buye } ``` -Once the human registers, the request resolves: +Once the human registers, the request resolves. It carries `account` with the new account's money terms, because they can differ from the ones requested (`requested`, `created_at`, and `updated_at` are omitted here): ```json { "request_id": "areq_01K4F8M2Q7", "state": "resolved", "account_id": "acc_renault_fr_wpp", - "resolution": "created" + "resolution": "created", + "account": { + "account_id": "acc_renault_fr_wpp", + "name": "Renault France via WPP Media", + "status": "active", + "billing": "operator", + "payment_terms": "net_45" + } } ``` @@ -227,4 +234,4 @@ A seller that does deduplicate typically answers a repeated *open* request as fo | [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) (money change) | A change to `billing`, `billing_entity`, or `payment_terms` without `revision`. | | [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported) | The requested `billing` isn't in the seller's `supported_billing`. Sellers MAY instead record the request and reject it. | | [`IDEMPOTENCY_CONFLICT`](/docs/building/verification/compliance-catalog#error-code-idempotency-conflict) | The `idempotency_key` was replayed with a different body. | -| [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) | The seller doesn't support account requests, or refuses to change `currency`, `timezone`, or `sandbox` on an existing account. | +| [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) | The seller doesn't support account requests; or a change names `currency` or `timezone` and the seller doesn't list it in `changeable_attributes`; or a change names `sandbox`. | diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 0c7f13e29bd..31cd9dd8393 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -664,7 +664,7 @@ advertising `supported: true` MUST also list `account.requests` in `event_type` is present only when the seller fires `account_request.changed`; a seller that declares it also declares `adcp.capability_changes.notifications.supported: true`, because buyers subscribe through the caller-scoped `notification_configs`. Without it, buyers poll `list_accounts` with `request_ids`. -`changeable_attributes` lists the attributes the seller accepts in `request_account` change requests (`account_id` plus fields); absent or empty means the seller supports opening accounts only. +`changeable_attributes` lists the attributes the seller accepts in `request_account` change requests (`account_id` plus fields); absent or empty means the seller supports opening accounts only. `currency` and `timezone` change only when listed: a seller that changes an account's timezone through `request_account` lists `timezone`, and refuses an unlisted `currency` or `timezone` change with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). `pending_action_kinds` is advisory: it lets buyers prepare the people who will act. Sellers may issue other kinds, and buyers handle unknown kinds through the diff --git a/docs/protocol/sync_principal.mdx b/docs/protocol/sync_principal.mdx index d5eddda046f..26ac7afcadc 100644 --- a/docs/protocol/sync_principal.mdx +++ b/docs/protocol/sync_principal.mdx @@ -329,7 +329,7 @@ For example, if a seller offers `package`, `package × creative`, and `package `reconciliation_mode` must also match the offering. Without a `destination` the subscription selects only API-delivered (Core) offerings; with one, only offerings whose `method` matches that destination's pattern and transport. Billing subscriptions require `official` finality and `consumer_receipt`, and `consumer_receipt` requires a destination. The seller resolves the selector separately for each account and each package of a covered buy, against that package's product. A package resolves when its buy and subscription version first meet, at the later of the version's `accepted_at` and the buy's `confirmed_at`, and keeps that offering for the version until the seller withdraws it. A later offering never re-resolves it; a new version resolves afresh. The buy owes every offering any of its packages resolves, so even under `exact` or `finest` a buy whose packages come from different products can owe two offerings, each with its own periods. Each package on `get_media_buys` carries its own `reporting_delivery_offering_ids`, fixed when the package is confirmed, and each offering publishes `available_from` and `withdrawn_at`, so a buyer resolves every package from the wire. Each (buy, resolved offering) pair is owed its own periods, and each obligation names its `offering_id` and `report_definition_id`. An account or buy with no matching offering gets an `offering_unsupported` exception. When only some of a buy's packages resolve, the buy is covered for those, and the exception names the uncovered `package_ids`. Neither case fails the request or the subscription. -**Period boundaries.** The resolved offering's published schedule governs period boundaries: its alignment (account timezone, UTC, an upstream source timezone, or a billing cycle), anchor, and timezone, which the buyer reads from the offering. Use the selector's `alignment` to require one. `period_timezone` overrides the timezone for a subscription scoped to exactly one account. Years, months, and days are civil time in that timezone; hours, minutes, and seconds are exact elapsed time, so `PT24H` and `P1D` differ across a DST change. Sub-daily periods under a local alignment restart at each local midnight instead: hourly periods start on the local hour in every zone, a 23-hour DST day has 23 of them and a 25-hour day 25, and the duration must divide 24 hours. Each local date's first instant is always a boundary, even where midnight doesn't exist (Havana, Santiago, Cairo), so days nest in every sub-daily schedule and `PT24H` equals `P1D`. A 30-minute DST change, as on Lord Howe Island, makes one hourly period 90 minutes long. Weeks (`P7D`) under `utc`, `account_timezone`, and `source_timezone` start on Thursday, the weekday of 1970-01-01; `billing_cycle` with an anchor gives another week start, and `billing_cycle` always needs a date component. When an account's timezone changes, for example through a [`request_account`](/docs/accounts/tasks/request_account) change, account-timezone periods switch at the first old-timezone boundary at or after the change. The switch is recorded in `get_reporting_status` as a `timezone_cutovers` entry, and [`list_accounts`](/docs/accounts/tasks/list_accounts) shows the new `timezone` and `revision`. +**Period boundaries.** The resolved offering's published schedule governs period boundaries: its alignment (account timezone, UTC, an upstream source timezone, or a billing cycle), anchor, and timezone, which the buyer reads from the offering. Use the selector's `alignment` to require one. `period_timezone` overrides the timezone for a subscription scoped to exactly one account. Years, months, and days are civil time in that timezone; hours, minutes, and seconds are exact elapsed time, so `PT24H` and `P1D` differ across a DST change. Sub-daily periods under a local alignment restart at each local midnight instead: hourly periods start on the local hour in every zone, a 23-hour DST day has 23 of them and a 25-hour day 25, and the duration must divide 24 hours. Each local date's first instant is always a boundary, even where midnight doesn't exist (Havana, Santiago, Cairo), so days nest in every sub-daily schedule and `PT24H` equals `P1D`. A 30-minute DST change, as on Lord Howe Island, makes one hourly period 90 minutes long. Weeks (`P7D`) under `utc`, `account_timezone`, and `source_timezone` start on Thursday, the weekday of 1970-01-01; `billing_cycle` with an anchor gives another week start, and `billing_cycle` always needs a date component. When an account's timezone changes, for example through a [`request_account`](/docs/accounts/tasks/request_account) change on a seller that lists `timezone` in `account.requests.changeable_attributes`, account-timezone periods switch at the first old-timezone boundary at or after the change. The switch is recorded in `get_reporting_status` as a `timezone_cutovers` entry, and [`list_accounts`](/docs/accounts/tasks/list_accounts) shows the new `timezone` and `revision`. **Destinations.** `destination.destination_id` names an entry in this principal's `reporting_destinations`, which must still exist after the same request applies. Every delivered row carries `account_id`, so a seller advertising subscriptions publishes every managed-delivery offering with a row schema that declares `account_id` as a required column and lists it in `primary_keys`. A `path_template` is a relative path with no `.` or `..` segments; allowed placeholders are `{account_id}`, `{subscription_id}`, `{offering_id}`, and `{period_start}`. Each substituted value fills exactly one path segment: the seller percent-encodes (RFC 3986) any value that contains `/` or equals `.` or `..`. It must contain `{account_id}` when the scope can cover more than one account, and is then required for `file_transfer` destinations. When more than one active subscription of the principal shares a destination, every template must contain `{account_id}` and the templates must differ. When an agency must keep advertisers apart, `destination_overrides[]` routes some accounts, by `account_ids` or `operator_domains`, to another destination inside the same subscription; an `account_ids` entry wins over an `operator_domains` one, and every override destination has the same pattern and transport as `destination`. An account-scoped subscription pointing at its own destination also works, and sellers that support subscriptions MUST support that. Changing overrides needs a new version, like any destination change. diff --git a/static/schemas/source/account/request-account-request.json b/static/schemas/source/account/request-account-request.json index 8e0cbab6457..3b3203263a2 100644 --- a/static/schemas/source/account/request-account-request.json +++ b/static/schemas/source/account/request-account-request.json @@ -4,7 +4,7 @@ "title": "Request Account Request", "x-added-in": "3.3.0", "x-status": "experimental", - "description": "The single account write (experimental, account.requests). Open an account (brand and operator: the seller finds an existing one or creates one) or change one (account_id plus the attributes to change), possibly after human review on either side. Every write is a request with the same lifecycle. Experimental in 3.3 (#8280): sellers implementing this task MUST list account.requests in experimental_features. Retries are safe through idempotency_key; deduplicating separate requests for the same advertiser is seller discretion, with non-normative guidance in the task docs. Send either account_id, or brand and operator; sellers enforce this at runtime so the MCP input schema stays a plain root object, and reject a request carrying both, or neither, with INVALID_REQUEST. With account_id, every other field present is a requested change, validated as when the account opens: brand.domain and brand.brand_id must equal the account's (a different brand is a different account, INVALID_REQUEST), currency and timezone changes MAY be refused with UNSUPPORTED_FEATURE, and sandbox never changes. Authorization (change or select): the seller first resolves account_id against the caller's own access and answers an unreachable account exactly as an unknown one, before any other validation of the body. A change requires request_account in the caller's allowed_tasks for that account and read_only: false; when the grant lists request_account under field_scopes, every changed field (including billing, billing_entity, and payment_terms) must be in that allowlist, as field_scopes already works. Otherwise SCOPE_INSUFFICIENT, READ_ONLY_SCOPE, or FIELD_NOT_PERMITTED. A change that goes in_review re-checks revision when it resolves; if the account changed meanwhile, the request is rejected with CONFLICT. A new change request for the same account from the same caller supersedes that caller's open change request: the new request gets its own request_id and must carry every change still wanted, and the open one moves to rejected with CONFLICT and details.superseded_by. It never affects another caller's request.", + "description": "The single account write (experimental, account.requests). Open an account (brand and operator: the seller finds an existing one or creates one) or change one (account_id plus the attributes to change), possibly after human review on either side. Every write is a request with the same lifecycle. Experimental in 3.3 (#8280): sellers implementing this task MUST list account.requests in experimental_features. Retries are safe through idempotency_key; deduplicating separate requests for the same advertiser is seller discretion, with non-normative guidance in the task docs. Send either account_id, or brand and operator; sellers enforce this at runtime so the MCP input schema stays a plain root object, and reject a request carrying both, or neither, with INVALID_REQUEST. With account_id, every other field present is a requested change, validated as when the account opens: brand.domain and brand.brand_id must equal the account's (a different brand is a different account, INVALID_REQUEST), currency and timezone change only on sellers that list them in account.requests.changeable_attributes and are otherwise refused with UNSUPPORTED_FEATURE, and sandbox never changes. A timezone change is a reporting cutover for account_timezone periods. Authorization (change or select): the seller first resolves account_id against the caller's own access and answers an unreachable account exactly as an unknown one, before any other validation of the body. A change requires request_account in the caller's allowed_tasks for that account and read_only: false; when the grant lists request_account under field_scopes, every changed field (including billing, billing_entity, and payment_terms) must be in that allowlist, as field_scopes already works. Otherwise SCOPE_INSUFFICIENT, READ_ONLY_SCOPE, or FIELD_NOT_PERMITTED. A change that goes in_review re-checks revision when it resolves; if the account changed meanwhile, the request is rejected with CONFLICT. A new change request for the same account from the same caller supersedes that caller's open change request: the new request gets its own request_id and must carry every change still wanted, and the open one moves to rejected with CONFLICT and details.superseded_by. It never affects another caller's request.", "x-tool-summary": "Request an advertiser account: the seller matches an existing account or creates one, possibly after human review.", "type": "object", "allOf": [{ "$ref": "/schemas/core/version-envelope.json" }], diff --git a/static/schemas/source/account/request-account-response.json b/static/schemas/source/account/request-account-response.json index 5f493538236..0480cbd456b 100644 --- a/static/schemas/source/account/request-account-response.json +++ b/static/schemas/source/account/request-account-response.json @@ -78,6 +78,13 @@ }, "account_id": "acc_nova_pinnacle", "resolution": "created", + "account": { + "account_id": "acc_nova_pinnacle", + "name": "Nova Athletics via Pinnacle Media", + "status": "active", + "billing": "agent", + "payment_terms": "net_30" + }, "created_at": "2026-10-18T09:05:00Z", "updated_at": "2026-10-18T09:05:00Z" } diff --git a/static/schemas/source/core/account-request-record.json b/static/schemas/source/core/account-request-record.json index 2af0fa9d5da..baafbba087b 100644 --- a/static/schemas/source/core/account-request-record.json +++ b/static/schemas/source/core/account-request-record.json @@ -46,12 +46,31 @@ }, "account": { "$ref": "/schemas/core/account.json", - "description": "Present when state is resolved: the account after the request, including any changed attributes and its revision. Sellers SHOULD include it when resolution is updated, and MAY omit it otherwise." + "description": "Present when state is resolved: the account after the request, including any changed attributes and its revision. Sellers SHOULD include it when resolution is updated. When resolution is matched or created, sellers MUST include it, with the account's money terms (billing, billing_entity without write-only fields, and payment_terms) whenever the account has them, because the account the request resolved to can carry terms other than those requested." }, "resolution": { "$ref": "/schemas/enums/account-request-resolution.json", "description": "Present when state is resolved: whether the seller matched an existing account, created one, or updated the named account. A change request can resolve as any of the three." }, + "effective_at": { + "type": "string", + "format": "date-time", + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Present when and only when the request resolved as updated and changed billing, billing_entity, or payment_terms: when the new money terms take effect. Delivery before it is invoiced under the previous terms, and delivery from it under the new ones. At or after the instant the request resolved; a seller MAY choose a later instant, such as its next invoice-period boundary. Changes to other attributes take effect when the request resolves." + }, + "not_carried_over": { + "type": "array", + "x-status": "experimental", + "x-added-in": "3.3.0", + "items": { + "type": "string", + "enum": ["notification_configs", "reporting_delivery_configs", "reporting_bucket", "governance_agents", "creatives", "audiences", "catalogs", "event_sources"] + }, + "maxItems": 8, + "uniqueItems": true, + "description": "Present when and only when a change request resolved as created, a separate new account: the named account's account-level configurations the new account does not have, so the buyer knows what to set up again. notification_configs, reporting_delivery_configs, reporting_bucket, and governance_agents are the account fields of the same names; creatives, audiences, catalogs, and event_sources are account-scoped libraries synced through their own tasks. [] means every one of them carried over. Principal-level configuration is unaffected: an all_authorized_accounts reporting subscription covers the new account from its authorization, while an account_ids subscription covers it only once the buyer adds it." + }, "candidates": { "type": "array", "description": "Present when state is ambiguous. Accounts that match the request, limited to accounts the caller is already granted. The seller MUST NOT list accounts the caller cannot already reach. When the only matches are outside the caller's grants, every caller-visible state, pending-action kind, and error MUST be one the seller could also return when nothing matches.", @@ -79,6 +98,27 @@ }, "required": ["request_id", "state", "requested", "created_at", "updated_at"], "allOf": [ + { + "if": { "properties": { "state": { "const": "resolved" }, "resolution": { "enum": ["matched", "created"] } }, "required": ["state", "resolution"] }, + "then": { "required": ["account"] } + }, + { + "if": { "properties": { "state": { "const": "resolved" }, "resolution": { "const": "created" }, "requested": { "required": ["account_id"] } }, "required": ["state", "resolution", "requested"] }, + "then": { "required": ["not_carried_over"] }, + "else": { "not": { "required": ["not_carried_over"] } } + }, + { + "if": { + "properties": { + "state": { "const": "resolved" }, + "resolution": { "const": "updated" }, + "requested": { "anyOf": [{ "required": ["billing"] }, { "required": ["billing_entity"] }, { "required": ["payment_terms"] }] } + }, + "required": ["state", "resolution", "requested"] + }, + "then": { "required": ["effective_at"] }, + "else": { "not": { "required": ["effective_at"] } } + }, { "if": { "properties": { "state": { "const": "in_review" } }, "required": ["state"] }, "then": { "required": ["pending_action"], "not": { "anyOf": [{ "required": ["account_id"] }, { "required": ["account"] }, { "required": ["candidates"] }] } } @@ -119,18 +159,25 @@ } }, { - "description": "Matched to an existing account", + "description": "Matched to an existing account, with that account's money terms", "data": { "request_id": "areq_01K4F8M2Q7", "state": "resolved", "requested": { - "brand": { "domain": "renault.fr", "countries": ["FR"] }, - "operator": "wppmedia.fr", + "brand": { "domain": "nova-athletics.example" }, + "operator": "pinnacle-media.example", "currency": "EUR", "billing": "operator" }, - "account_id": "acc_renault_fr_wpp", + "account_id": "acc_nova_pinnacle", "resolution": "matched", + "account": { + "account_id": "acc_nova_pinnacle", + "name": "Nova Athletics via Pinnacle Media", + "status": "active", + "billing": "operator", + "payment_terms": "net_45" + }, "created_at": "2026-10-18T09:00:00Z", "updated_at": "2026-10-19T14:30:00Z" } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 2c068668e8f..62ecf04844a 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1439,7 +1439,7 @@ "read_task": { "type": "string", "const": "list_accounts" }, "changeable_attributes": { "type": "array", - "description": "Account attributes this seller accepts in request_account change requests (account_id plus fields). Absent or empty means the seller supports opening accounts only. Advisory: sellers still validate each change.", + "description": "Account attributes this seller accepts in request_account change requests (account_id plus fields). Absent or empty means the seller supports opening accounts only. Advisory: sellers still validate each change. currency and timezone are changeable only when listed; a seller that changes an account's timezone through request_account MUST list timezone, and refuses a change to an unlisted currency or timezone with UNSUPPORTED_FEATURE.", "items": { "type": "string", "enum": ["countries", "operator_unit", "billing", "billing_entity", "payment_terms", "currency", "timezone"] }, "uniqueItems": true }, diff --git a/tests/account-requests.test.cjs b/tests/account-requests.test.cjs index 6fb22f22e23..61d5b30dbf5 100644 --- a/tests/account-requests.test.cjs +++ b/tests/account-requests.test.cjs @@ -63,7 +63,7 @@ test('each request state carries exactly its own fields', () => { const v = validate('/schemas/core/account-request-record.json'); const ok = [ { ...base, state: 'in_review', pending_action: sellerReview }, - { ...base, state: 'resolved', account_id: 'acc_1', resolution: 'matched' }, + { ...base, state: 'resolved', account_id: 'acc_1', resolution: 'matched', account: { account_id: 'acc_1', name: 'Matched account', status: 'active', billing: 'operator', payment_terms: 'net_30' } }, { ...base, state: 'ambiguous', candidates }, { ...base, state: 'rejected', errors: [{ code: 'POLICY_VIOLATION', message: 'Category not accepted.' }] }, ]; @@ -243,8 +243,8 @@ test('a change request can resolve to a separate or existing account, leaving th updated_at: '2026-10-29T09:00:00Z', }; const separate = { account_id: 'acc_nova_nl_2', name: 'Nova Athletics NL', status: 'active', revision: 1 }; - assert.ok(v({ ...change, state: 'resolved', account_id: 'acc_nova_nl_2', resolution: 'created', account: separate }), JSON.stringify(v.errors)); - assert.ok(v({ ...change, state: 'resolved', account_id: 'acc_nova_nl', resolution: 'matched' }), JSON.stringify(v.errors)); + assert.ok(v({ ...change, state: 'resolved', account_id: 'acc_nova_nl_2', resolution: 'created', account: separate, not_carried_over: [] }), JSON.stringify(v.errors)); + assert.ok(v({ ...change, state: 'resolved', account_id: 'acc_nova_nl', resolution: 'matched', account: { account_id: 'acc_nova_nl', name: 'Nova Athletics NL', status: 'active' } }), JSON.stringify(v.errors)); }); test('a separate account on a change is opt-in; otherwise the seller rejects with ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT', () => { @@ -317,3 +317,49 @@ test('request_account change mode is authorized, reachability-first, and continu const caps = read('protocol/get-adcp-capabilities-response.json').properties.account.properties.requests.oneOf[0].properties; assert.ok(caps.changeable_attributes, 'sellers declare which attributes they change'); }); + +// Account follow-ups (3.3.0-beta.3). +test('matched and created resolutions carry the target account and its money terms', () => { + const record = read('core/account-request-record.json'); + assert.match(record.properties.account.description, /When resolution is matched or created, sellers MUST include it, with the account's money terms/); + const v = validate('/schemas/core/account-request-record.json'); + const target = { account_id: 'acc_nova_pinnacle', name: 'Nova Athletics via Pinnacle Media', status: 'active', billing: 'operator', payment_terms: 'net_45' }; + for (const resolution of ['matched', 'created']) { + assert.ok(v({ ...base, state: 'resolved', account_id: 'acc_nova_pinnacle', resolution, account: target }), JSON.stringify(v.errors)); + assert.equal(v({ ...base, state: 'resolved', account_id: 'acc_nova_pinnacle', resolution }), false, `${resolution} without account`); + } + for (const example of record.examples) assert.ok(v(example.data), example.description); + const response = validate('/schemas/account/request-account-response.json'); + for (const example of read('account/request-account-response.json').examples) assert.ok(response(example.data), example.description); +}); + +test('money changes carry effective_at; a separate account lists what did not carry over', () => { + const record = read('core/account-request-record.json'); + assert.equal(record.properties.effective_at['x-added-in'], '3.3.0'); + assert.equal(record.properties.not_carried_over['x-added-in'], '3.3.0'); + assert.deepEqual(record.properties.not_carried_over.items.enum, ['notification_configs', 'reporting_delivery_configs', 'reporting_bucket', 'governance_agents', 'creatives', 'audiences', 'catalogs', 'event_sources']); + const v = validate('/schemas/core/account-request-record.json'); + const account = { account_id: 'acc_nova', name: 'Nova', status: 'active', revision: 8, payment_terms: 'net_60' }; + const money = { request_id: 'areq_money', requested: { account_id: 'acc_nova', revision: 7, payment_terms: 'net_60' }, created_at: '2026-10-28T09:00:00Z', updated_at: '2026-10-28T09:00:00Z', state: 'resolved', account_id: 'acc_nova', resolution: 'updated', account }; + assert.ok(v({ ...money, effective_at: '2026-11-01T00:00:00Z' }), JSON.stringify(v.errors)); + assert.equal(v(money), false, 'a money change needs effective_at'); + const countries = { ...money, requested: { account_id: 'acc_nova', brand: { domain: 'nova-athletics.example', countries: ['NL'] } } }; + assert.ok(v(countries), JSON.stringify(v.errors)); + assert.equal(v({ ...countries, effective_at: '2026-11-01T00:00:00Z' }), false, 'effective_at only on money changes'); + + const separate = { request_id: 'areq_sep', requested: { account_id: 'acc_nova', brand: { domain: 'nova-athletics.example', countries: ['NL'] } }, created_at: '2026-10-28T09:00:00Z', updated_at: '2026-10-28T09:00:00Z', state: 'resolved', account_id: 'acc_nova_nl', resolution: 'created', account: { account_id: 'acc_nova_nl', name: 'Nova NL', status: 'active' } }; + assert.ok(v({ ...separate, not_carried_over: ['reporting_delivery_configs', 'governance_agents'] }), JSON.stringify(v.errors)); + assert.equal(v(separate), false, 'a separate account says what did not carry over'); + assert.equal(v({ ...separate, requested: { brand: { domain: 'nova-athletics.example' }, operator: 'pinnacle-media.example' }, not_carried_over: [] }), false, 'not on an open request'); +}); + +test('timezone is changeable only where changeable_attributes lists it', () => { + const caps = read('protocol/get-adcp-capabilities-response.json').properties.account.properties.requests.oneOf[0].properties.changeable_attributes; + assert.ok(caps.items.enum.includes('timezone')); + assert.match(caps.description, /a seller that changes an account's timezone through request_account MUST list timezone/); + assert.match(read('account/request-account-request.json').description, /currency and timezone change only on sellers that list them in account\.requests\.changeable_attributes/); + const doc = fs.readFileSync(path.join(ROOT, 'docs/accounts/tasks/request_account.mdx'), 'utf8'); + assert.doesNotMatch(doc, /The changeable attributes are those \[#8474\]/); + assert.match(doc, /change only on a seller that lists them/); + assert.match(fs.readFileSync(path.join(ROOT, 'docs/protocol/sync_principal.mdx'), 'utf8'), /on a seller that lists `timezone` in `account\.requests\.changeable_attributes`/); +});