diff --git a/.changeset/selection-dependent-price-adjustments.md b/.changeset/selection-dependent-price-adjustments.md new file mode 100644 index 00000000000..55326025edc --- /dev/null +++ b/.changeset/selection-dependent-price-adjustments.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add optional `price_adjustments` to binding fixed-price pricing options (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`) (#7659). The surface is experimental under the feature id `media_buy.price_adjustments`. A table is optional and has equal standing with a fully priced seller offer: whether a table is present tells the buyer if it can compute the price locally, and without one the price applies only to what was quoted; yield-managed sellers are not expected to publish a table. A seller whose rate depends on buyer-selected values or on the delivery date declares an ordered table of `fee`/`discount` rows, each with a `rate` (a proportion of the running total; a fee rate may be 1 or more, so a Dutch index-card factor `f` is a fee rate of `f - 1` or a discount rate of `1 - f`) or a fixed `amount`. Rows are keyed by `format_option` (a `format_option_ref`, which also carries spot length), `placement_selection` (a `placement_ref`, requiring `overlay_support.placement_selection`), or `always`, and may carry a `valid_from`/`valid_until` window evaluated at the purchase `start_time`; a flight crossing a window boundary is rejected. Resolution is normative: selection rows fire at most once per dimension (first applicable row in declared order), then each firing row applies to one running total in declared order (a 10% fee then a 10% fee is x1.21), every step rounds half away from zero to the option's price precision (the greatest of the currency minor-unit exponent, 4 decimals for per-unit models, and the decimals needed to represent declared values exactly, by numeric value), and the result is never negative. The `price_breakdown` rounding sentence is amended to match. A table is bound to its quote: it applies only to the account it was returned for and expires with the proposal or offer `expires_at` or the discovery response's validity. The accepted purchase's `pricing.fixed_price` is the resolved rate and `price_breakdown` records each fired row in declared order (both existing fields), so a verifier can re-resolve it. The table is not embedded in the accepted snapshot, because the canonical schemas are closed and a minor release does not add properties to closed schemas (embedding it is a 4.0 item, #8501): any change to the table mints a new `pricing_option_id`, so the accepted `pricing_option_id` (covered by `terms_digest`) identifies exactly one immutable table, both parties retain it from discovery, and the seller MUST price changes the agreement already permits from it, not from a later table; the table grants no change rights (those come only from `change_terms[]`). A buyer-supplied `pricing` that differs from the resolved result is rejected with `INVALID_REQUEST` carrying the resolved price and breakdown (`error-details/price-adjustments-mismatch.json`). Sellers must not return an option carrying the table to callers pinned below 3.3. The uniform-price language in `Product.pricing_options`, `overlay_support`, and the product, targeting, and pricing docs is amended to except declared adjustments. v1 dimensions are placement and format; buyer-owned property and collection list references have no seller-side anchor and are out, as are auction floors, activation method, measurement vendor, reporting tier, and the `cpa`/`time` models. Adds resolution vectors, including a fictional Dutch-style TV rate card, under `test-vectors/price-adjustments`. diff --git a/docs/building/by-layer/L3/error-handling.mdx b/docs/building/by-layer/L3/error-handling.mdx index 34995cfd5e7..cbb1b88c2d9 100644 --- a/docs/building/by-layer/L3/error-handling.mdx +++ b/docs/building/by-layer/L3/error-handling.mdx @@ -532,7 +532,7 @@ The wire-level `recovery: "correctable"` on the sandbox-only path is the registe | [`AUDIENCE_TOO_SMALL`](/docs/building/verification/compliance-catalog#error-code-audience-too-small) | correctable | Audience segment below minimum size | Broaden targeting or upload more audience members | {/* /schemas/latest/ link: schema unreleased; switch to the v3 alias after the next release. */} -For `UNSUPPORTED_FEATURE` rejecting an unsupported or removed targeting dimension, details SHOULD follow [`error-details/unsupported-targeting-dimension.json`](https://adcontextprotocol.org/schemas/latest/error-details/unsupported-targeting-dimension.json); see [Rejected targeting dimensions](/docs/media-buy/advanced-topics/targeting#rejected-targeting-dimensions). For `REQUOTE_REQUIRED`, details SHOULD follow [`error-details/requote-required.json`](https://adcontextprotocol.org/schemas/v3/error-details/requote-required.json). For [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) with `reason: "not_supported_on_buy"`, the existing [`error-details/action-not-allowed.json`](https://adcontextprotocol.org/schemas/v3/error-details/action-not-allowed.json) shape also permits `decline_reason`. Sellers SHOULD emit the coarse reason when they have a structured seller-policy classification and disclosure does not expose private thresholds or enforcement controls. Buyers MUST tolerate its absence and MUST NOT infer that an undisclosed policy does not exist. Structural action denials SHOULD omit it. +For `UNSUPPORTED_FEATURE` rejecting an unsupported or removed targeting dimension, details SHOULD follow [`error-details/unsupported-targeting-dimension.json`](https://adcontextprotocol.org/schemas/latest/error-details/unsupported-targeting-dimension.json); see [Rejected targeting dimensions](/docs/media-buy/advanced-topics/targeting#rejected-targeting-dimensions). For an `INVALID_REQUEST` that rejects a buyer-supplied `pricing` differing from the price a `price_adjustments` table resolves to, details SHOULD follow [`error-details/price-adjustments-mismatch.json`](https://adcontextprotocol.org/schemas/v3/error-details/price-adjustments-mismatch.json). For `REQUOTE_REQUIRED`, details SHOULD follow [`error-details/requote-required.json`](https://adcontextprotocol.org/schemas/v3/error-details/requote-required.json). For [`ACTION_NOT_ALLOWED`](/docs/building/verification/compliance-catalog#error-code-action-not-allowed) with `reason: "not_supported_on_buy"`, the existing [`error-details/action-not-allowed.json`](https://adcontextprotocol.org/schemas/v3/error-details/action-not-allowed.json) shape also permits `decline_reason`. Sellers SHOULD emit the coarse reason when they have a structured seller-policy classification and disclosure does not expose private thresholds or enforcement controls. Buyers MUST tolerate its absence and MUST NOT infer that an undisclosed policy does not exist. Structural action denials SHOULD omit it. **Implementation note (non-normative).** Repeated out-of-envelope controls can be used to map a seller's exact share-of-voice, notice-window, frequency, or commitment thresholds. Consider rate-limiting these deterministic probes using the same posture as [`refine_proposals`](/docs/media-buy/product-discovery/proposal-negotiation#log-for-audit-without-leaking-terms), and omit `decline_reason` when even the coarse category would disclose sensitive enforcement logic. diff --git a/docs/creative/channels/broadcast.mdx b/docs/creative/channels/broadcast.mdx index 7daa19a04a9..e41d1643c12 100644 --- a/docs/creative/channels/broadcast.mdx +++ b/docs/creative/channels/broadcast.mdx @@ -227,10 +227,9 @@ This works today with four gaps. Read them before building on it: - **The demo is bound on the forecast, not on the price.** A CPM pricing option has no demographic parameters, unlike a CPP option (`parameters.demographic_system`, `demographic`). `measurement_terms.billing_measurement` binds the vendor, the window, and the variance, but has no demographic field. - **The guaranteed volume has no typed definition.** `purchases[].impressions` is the contracted volume, and a buyer can require a floor on it with `refine_proposals` `constraints.impressions` (a hard minimum summed across purchases). The schema does not say that number counts measured audience, in which demo, from which vendor, or on which window. Those facts sit on the forecast and `billing_measurement`, and the forecast is outside `commercial_terms`, so it is not covered by `terms_digest`. Treat the demo as a term agreed in the human contract outside AdCP, not one a protocol agent can verify. -- **:30 equivalization is not expressible.** Nothing declares whether a :15 impression counts as half a :30 unit or the factor to apply. A CPM prices impressions regardless of unit length, so on a buy that mixes :15 and :30 spots the equivalization rule is a term to settle outside the protocol. +- **:30 equivalization is only partly expressible.** Rate-side spot-length indexing is expressible through [`price_adjustments`](/docs/media-buy/advanced-topics/pricing-models#selection-dependent-price-adjustments) (a rate row keyed to the spot's format option). Impression-side :30 equivalization and demo-bound CPM remain open and are tracked for 3.4 in [#8001](https://github.com/adcontextprotocol/adcp/issues/8001). Nothing declares whether a :15 impression counts as half a :30 unit or the factor to apply, so on a buy that mixes :15 and :30 spots the impression-side rule is a term to settle outside the protocol. - **No under-delivery trigger, and the schedule on the wire is experimental.** `makegood_policy` does not name a volume shortfall as a trigger. A seller may report the planned schedule as `scheduled` rows in `by_spot`, but that field is experimental and seller-declared, so a buyer cannot assume one. See [Makegoods](#makegoods). -Demo-bound CPM and :30 equivalization are tracked for 3.4 in [#8001](https://github.com/adcontextprotocol/adcp/issues/8001). ### 1. The seller publishes the product diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx index acc9c34dd73..1d3720e014d 100644 --- a/docs/media-buy/advanced-topics/pricing-models.mdx +++ b/docs/media-buy/advanced-topics/pricing-models.mdx @@ -676,7 +676,7 @@ For amount-based discounts: running = running − amount Operator fees use the fee formulas. -All monetary values are rounded to the precision of the currency (e.g., 2 decimal places for EUR/USD) at each step. The invariant holds after rounding. +All monetary values are rounded to the precision of the currency (e.g., 2 decimal places for EUR/USD) at each step, or, for an option that declares [`price_adjustments`](#selection-dependent-price-adjustments), to that option's price precision, which can be finer. The invariant holds after rounding. Example with mixed adjustments: @@ -790,6 +790,119 @@ A flat per-campaign fee or retainer is not a per-unit adjustment and is out of s --- +## Selection-dependent price adjustments + + +Experimental (`media_buy.price_adjustments`): sellers that implement `price_adjustments` declare the feature id in `experimental_features`, and the shape may change in 3.x with the notice described in [Experimental status](/docs/reference/experimental-status). The rule that a seller MUST NOT return a table to callers below 3.3 applies in addition. + + +### Two supported paths + +A seller can quote a price in either of two equally supported ways: + +- **A fully priced offer.** The seller returns a pricing option, or a configured product or proposal, whose `fixed_price` is the price for what was quoted. It publishes no table. The price applies only to that quote: the buyer cannot derive the price of a different selection, date range, or volume from it and re-discovers or refines to learn it. +- **A fixed price plus an adjustment table.** A fixed-price `cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, or `flat_rate` option declares an ordered `price_adjustments` table, so the buyer can compute the price locally for the selections and dates the table covers. + +Declaring a table is optional and a fully priced offer is not a fallback. Whether a table is present tells the buyer whether it can compute the price itself. Many sellers, broadcast included, price the combination of property, dates, and format against avails and their own yield rules, and a seller managing yield this way SHOULD NOT be expected to publish a table or the method behind its prices. A table suits sellers whose rate really is a base rate with coefficients: Dutch linear TV rate cards multiply a net base price by month, spot-length, target-audience, and market indices. + +### Rows and composition + +`fixed_price` stays the **base rate**: it is what the buyer pays when no row applies, and it is already net of any negotiated discount in the option's own `price_breakdown`. This is distinct from `eligible_adjustments` (which kinds of adjustment are possible) and from `price_breakdown` (how a negotiated price was derived): `price_adjustments` is the seller's declared, binding function from selections and dates to price. + +Every row is a `fee` (adds) or a `discount` (subtracts), carrying exactly one of: + +- a **`rate`**: a proportion of the running total at that step. A fee `rate` can be 1 or more, so an index of 2.5 is a fee `rate` of 1.5; +- an **`amount`**: a fixed amount in the option's currency per pricing unit. + +There is no separate index row kind. A Dutch index card factor `f` is a fee `rate` of `f − 1` when `f > 1`, a discount `rate` of `1 − f` when `f < 1`, and no row when `f = 1`. The rate form needs no second kind, one rule covers percentage fees, discounts, and index cards, and a row means the same thing wherever it sits in the table. + +Each row has a `dimension`. `format_option` and `placement_selection` rows match values the buyer selects; `always` rows need no selection and apply whenever their window allows (month, market, and audience indices). + +```json +{ + "pricing_option_id": "oranje_grp_eur_2026", + "pricing_model": "cpp", + "currency": "EUR", + "fixed_price": 1000.00, + "parameters": { "demographic": "A25-54" }, + "price_adjustments": [ + { "dimension": "always", "kind": "fee", "name": "month_index_nov", "rate": 0.10, + "valid_from": "2026-11-01T00:00:00+01:00", "valid_until": "2026-12-01T00:00:00+01:00" }, + { "dimension": "always", "kind": "discount", "name": "market_index_nov", "rate": 0.05, + "valid_from": "2026-11-01T00:00:00+01:00", "valid_until": "2026-12-01T00:00:00+01:00" }, + { "dimension": "format_option", "format_option_ref": { "scope": "product", "format_option_id": "spot_20s" }, + "kind": "discount", "name": "spot_length_20s", "rate": 0.20 }, + { "dimension": "always", "kind": "fee", "name": "target_audience_a25_54", "rate": 0.25 }, + { "dimension": "placement_selection", + "placement_ref": { "publisher_domain": "oranjetv.example", "placement_id": "first_in_break" }, + "kind": "fee", "name": "first_in_break_premium", "rate": 0.20 } + ] +} +``` + +A buyer booking a 20-second spot in first position for a flight in the second half of November pays, applying each row to the running total in declared order: `1000.00 → 1100.00` (month index 1.10) `→ 1045.00` (market index 0.95) `→ 836.00` (20-second index 0.80) `→ 1045.00` (audience index 1.25) `→ 1254.00` (first-in-break fee of 20% of the running total, `209.00`). The accepted `price_breakdown` lists the five rows in that order: fee `0.10`, discount `0.05`, discount `0.20`, fee `0.25`, fee `0.20`. + +Rate-side spot-length indexing is expressible through [`price_adjustments`](#selection-dependent-price-adjustments) (a row keyed to the spot's format option). Impression-side :30 equivalization and demo-bound CPM remain open and are tracked for 3.4 in [#8001](https://github.com/adcontextprotocol/adcp/issues/8001). + +### Eligible dimensions + +Selection rows price only values that come from a seller-published catalog on the same product, so the seller can enumerate them in advance. The dimensions in this version are placement and format option (which carries spot length): + +| `dimension` | Row selector | Matches when | +|---|---|---| +| `format_option` | `format_option_ref` | The purchase's `format_option_refs` includes the reference. It MUST resolve to a format option the product declares. | +| `placement_selection` | `placement_ref` | A `mode: "selected"` `placement_selection` includes the placement. It MUST resolve to a targetable placement of the product, and the product MUST declare `overlay_support.placement_selection`. | +| `always` | none | Always, subject to the row's window. | + +Matching is full-identity equality after resolving both sides to the product's declared entry: a legacy placement ref and a placement identity for the same placement match, a seller-inline placement's `seller_agent.agent_url` compares under the [URL canonicalization rules](/docs/reference/url-canonicalization) rather than byte equality, and the same format option ID under a different `scope` does not. Only values the buyer actually selects match a selection row. Omitting the selection, or choosing the product default, matches no row. + +**Property and collection are out of this version.** `property_list` and `collection_list` are buyer-owned references (`agent_url` plus `list_id`): the buyer creates and changes the list, so the seller has no catalog entry to anchor a row to and cannot publish a price for a value it cannot enumerate. A row keyed to a list would be priced by the buyer's own list contents. A seller that prices property or collection differently quotes them as a fully priced offer for the properties or collections the buyer names, or sells them as separately priced products. A later minor version can add property by seller-side id, or collection by `collection_id`, once a row can name a value the seller publishes. + +Position in break is modelled as a seller-published placement (the example above prices `first_in_break` that way) until the booking half of [#8009](https://github.com/adcontextprotocol/adcp/issues/8009) lands; the reporting side is a separate contract ([Position pricing is outside this reporting contract](/docs/media-buy/media-buys/optimization-reporting)). Activation method, measurement vendor, and reporting tier have no selectable anchor yet; an unknown `dimension` is a schema error and later minor versions add values as anchors appear. + +### Validity windows + +A row may carry `valid_from` (inclusive) and `valid_until` (exclusive), both date-times with an offset, so a seller whose month and market indices change monthly publishes one row per window. A row without them always applies. The window is evaluated at the purchase's `start_time`. + +A purchase resolves to **one** price, so a flight whose `[start_time, end_time)` crosses a `valid_from` or `valid_until` of a row that would otherwise apply is rejected with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) naming the flight. The buyer splits the flight into one purchase per window. A flight may end exactly on a `valid_until`. Resolving each installment or each day of one purchase at its own rate would need a price per installment in the accepted snapshot, which this version does not define. + +Row windows are separate from quote expiry below: a window says when a rate is in force, expiry says how long the seller holds the quote. + +### Resolution + +Sellers and buyers MUST resolve a purchase's price in this order: + +1. **Select rows.** A row applies when its window contains the purchase's `start_time` and its selector matches. Among rows of one selection dimension, only the first applicable row in declared order fires, so a seller can list the same value once per window and the matching window wins. `always` rows each fire. Sellers who want multi-select purchases priced at the highest applicable row list it first; summing the matched values of one dimension is a recorded limit of 3.3, not supported in this version. +2. **Apply in declared order to one running total.** Start the running total at `fixed_price`. For each firing row, in the order the seller declared it, a `rate` row adds (`fee`) or subtracts (`discount`) `round(running × rate)` and an `amount` row adds or subtracts its `amount`; the result is the new running total. Because each row applies to the running total, a 10% fee followed by a 10% fee gives `× 1.21`, not `× 1.20`; a fixed amount followed by a rate is scaled by the rate; and the same rows declared in another order can resolve to a different price. The seller's declared order is part of the quote. +3. **Precision.** The option's **price precision** is the greatest of the currency's ISO 4217 minor-unit exponent; 4 for `cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, and `cpp` options; and the fewest decimal places that exactly represent `fixed_price` and each row `amount`. Precision is a property of the numeric values, not of how they were written: `0.0100` and `0.01` are the same value. Every step rounds half away from zero to price precision, so the resolved price is the exact running total and is not rounded again; rounding to currency minor units belongs to later spend and invoice calculation. Implementations MUST use exact decimal or integer arithmetic. +4. **Never negative.** The total MUST NOT fall below zero at the end. Sellers MUST verify at publication that no permitted selection resolves below zero; a purchase that would is rejected with `INVALID_REQUEST`, never clamped. + +This replaces the draft's earlier model, which applied index factors multiplicatively and then added percentage premiums to the indexed price without compounding them. The two give the same price for a table that declares its index rows before at most one percentage row and its `amount` rows last, which covers the Dutch example above; they differ when two percentage rows both apply, and when an `amount` row is declared before a rate row. + +Two consequences follow from one-row-per-dimension. A multi-select purchase pays the first applicable row's rate on all of its volume, so a seller that wants each selected value priced separately sells separate packages or publishes fully resolved per-selection options. And a row that prices a placement already in the product's default set would make choosing it explicitly cost more than the default, so sellers should price only values that add to the default. + +### Quotes stay bound to their terms + +A price, whether a fully priced offer or resolved from a table, is bound to what it was quoted for: its scope, applicable dates, buyer commitments, delivery terms, and the account it was quoted to. For a table: + +- **Account.** A table applies only to the account it was returned for. A buyer MUST NOT apply a table returned for one account to another account. A response with `cache_scope: "public"` was returned for every account that prices off the public rate card. +- **Expiry.** A table expires with the quote it came in: the proposal's or offer's `expires_at`, or, for a reusable product, the validity of the discovery response it was returned in (see `pricing_version` and `cache_scope` on `get_products`). After expiry the buyer re-discovers and uses the new table; it MUST NOT price a new purchase from the expired one. +- **Without a table.** A fully priced offer carries the same bounds. Its price applies only to what was quoted, and the buyer MUST NOT extrapolate it to other selections or dates. + +### Resolved price and accepted terms + +- When `pricing` is omitted from a purchase, the seller computes the resolved price. When the buyer supplies `pricing`, the seller MUST reject any `fixed_price` or `price_breakdown` that differs from the resolved result with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) naming the pricing field, and SHOULD carry the resolved value and breakdown as [`error-details/price-adjustments-mismatch.json`](https://adcontextprotocol.org/schemas/v3/error-details/price-adjustments-mismatch.json) (`details.resolved_fixed_price`, `details.resolved_price_breakdown`), so the buyer can see which row it disagrees on. It MUST NOT silently reprice the request. +- In the accepted purchase `pricing`, the seller MUST return `fixed_price` as the resolved rate. When any row fired, the seller MUST also return `price_breakdown`: its `list_price` is the declared option's `list_price` when the option declares a `price_breakdown`, and otherwise the option's declared `fixed_price`; its `adjustments` are the declared breakdown's adjustments followed by one entry per fired row, in declared order. A rate row is recorded with its `rate` when that rate is below 1, and as its currency delta in `amount` otherwise (a fee rate of 1 or more); an amount row is recorded as its `amount`; a row whose delta rounds to zero contributes nothing and is omitted. `list_price` folded through `adjustments` equals `fixed_price`, so the invariant above covers selection-dependent pricing. A verifier re-resolves the table of the accepted `pricing_option_id` over the purchase's selections and flight and compares. +- **The accepted `pricing_option_id` pins the table.** Any change to the table, or to which selections or dates it matches, is a change to binding price and mints a new `pricing_option_id`. So the accepted `pricing_option_id`, which `commercial_terms.terms_digest` covers, identifies exactly one immutable table, unfired rows and rows for other windows included. Changes the agreement already permits (a different format option or placement within the accepted `change_terms[]`) are priced from the table of the accepted `pricing_option_id` without a re-quote. Both parties retain that table from discovery, and the seller MUST price from it, not from a later table the option's successor carries. Quote expiry does not apply once the option is accepted: the accepted ID still names the table. +- **The table grants no change rights.** Pinning the table does not make any selection or date change allowed. Which changes the buyer may make come only from the accepted `change_terms[]` ([DR-0016](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0016-proposal-bound-buyer-change-rights.md)): a change they do not permit returns `ACTION_NOT_ALLOWED` whatever the table would price it at, and a change that exceeds the accepted commercial envelope returns `REQUOTE_REQUIRED`. A seller MUST NOT read a row for an unused value as consent to that change, and a buyer MUST NOT read it as an entitlement. +- **The table is not embedded in the accepted snapshot.** The canonical schemas that carry accepted terms (`canonical-pricing-option.json`, `product-purchase.json`, and the commercial terms and proposal that contain them) are closed, and a minor release does not add properties to closed schemas. Embedding the table in the snapshot, so the terms are self-contained without the retained discovery copy, is a 4.0 item tracked with [#8501](https://github.com/adcontextprotocol/adcp/issues/8501). + +A seller MUST NOT return an option carrying `price_adjustments` to a caller whose `adcp_version` is below 3.3, because an older buyer would read `fixed_price` as the whole price and be repriced at create; for those callers it publishes fully priced per-selection options or omits the option. A consumer that does not recognize a row's `dimension` treats the option as unpurchasable. The `fixed_price` is net of the option's declared discounts. Auction floors (`floor_price`) do not take adjustments, and `price_adjustments` requires `fixed_price`. Availability and forecasts stay seller-computed: the price resolves locally but feasibility does not. + +A seller can instead publish a fully priced pricing option per selection combination, or split the inventory into separately priced products. Both remain valid with equal standing; `price_adjustments` is for sellers whose dimensions are orthogonal enough that a combination catalog would explode and who are willing to publish the coefficients. + +--- + ## Eligible Adjustments Publishers can declare which adjustment kinds apply to a pricing option using `eligible_adjustments`. This tells buyer agents upfront whether discounts, commissions, or settlement terms are available — before any negotiation begins. Sellers never declare `operator_fee`, which only a buying platform applies. diff --git a/docs/media-buy/advanced-topics/targeting.mdx b/docs/media-buy/advanced-topics/targeting.mdx index 819f60732e0..54af7e507ca 100644 --- a/docs/media-buy/advanced-topics/targeting.mdx +++ b/docs/media-buy/advanced-topics/targeting.mdx @@ -816,8 +816,10 @@ Support guarantees selectability subject to disclosed limits, not inventory or a forecast for every possible value. A forecast returned without concrete metro values describes the product's discovery/default scope. Rediscover with the values in `targeting_overlay` for a metro-specific forecast. Fixed prices and -floors remain binding uniformly for supported selections; price guidance is -non-binding. If rates vary by value, the seller returns a configured product +floors remain binding uniformly for supported selections, except for adjustments +the selected fixed-price option declares in +[`price_adjustments`](/docs/media-buy/advanced-topics/pricing-models#selection-dependent-price-adjustments); +price guidance is non-binding. If other rates vary by value, the seller returns a configured product after concrete rediscovery, splits rate tiers into products, or exposes only non-binding guidance until concrete values are known. It never silently reinterprets a selected pricing option at a new price. diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index bd0c7f24631..bef087bf5ef 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -30,7 +30,7 @@ Products declare which pricing models they support. Buyers select a specific pri - `installments` (list[Installment], optional): Specific installments available within this product. The canonical Product that the compact lifecycle reads carries the same field with a different, closed item shape — `CanonicalInstallment`, which requires `collection_ref` and drops the deprecated `collection_id` shorthand. See [Collections and installments](/docs/media-buy/product-discovery/collections-and-installments#installments-in-the-compact-lifecycle). - `delivery_type` (string, required): Either `"guaranteed"` or `"non_guaranteed"`. - `exclusivity` (string, optional): Whether this product offers exclusive access. `"none"` (default when absent) — multiple advertisers can buy simultaneously. `"category"` — one advertiser per industry category. `"exclusive"` — sole sponsorship. Most relevant for guaranteed products tied to specific shows or placements. -- `pricing_options` (list[PricingOption], required): Array of available pricing models for this product. A binding option applies uniformly across later values permitted by `overlay_support`; a changed fixed price, floor, currency, model, or priced applicability receives a new `pricing_option_id`. Sellers with value-dependent rates use concretely targeted configured products, separate rate-tier products, or non-binding guidance. See [Pricing Models](#pricing-models). +- `pricing_options` (list[PricingOption], required): Array of available pricing models for this product. A binding option applies uniformly across later values permitted by `overlay_support`, except for [`price_adjustments`](/docs/media-buy/advanced-topics/pricing-models#selection-dependent-price-adjustments) the option itself declares; a changed fixed price, floor, currency, model, or priced applicability receives a new `pricing_option_id`. Sellers with value-dependent rates use concretely targeted configured products, separate rate-tier products, or non-binding guidance. See [Pricing Models](#pricing-models). - `delivery_measurement` (object, optional): Who measures ad delivery — the ad server and viewability vendor used to count impressions. New implementations populate `vendors` as a structured `BrandRef` array (e.g., `[{ "domain": "googleadmanager.com" }, { "domain": "integralads.com" }]`); the legacy `provider` string is deprecated. When absent, buyers should apply their own measurement defaults. See [Delivery Measurement](#delivery-measurement). - `outcome_measurement` (OutcomeMeasurement, **deprecated**): Legacy field for declaring business outcome measurement (lift, brand lift, foot traffic). New implementations declare outcome metrics via `reporting_capabilities.available_metrics` and pin attribution methodology + window via the `qualifier` slot on `committed_metrics`. Retained for one-minor backwards compatibility; removed at the next major. See [Commerce Media](/docs/media-buy/commerce-media) for the migration pattern. - `creative_policy` (CreativePolicy, optional): Creative requirements and restrictions, including the optional product-scoped `review_sla` for time to an approved or rejected decision. See [Service commitments](/docs/media-buy/service-commitments) for business calendars and critical-report SLAs. diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 4dfd7944c1d..7e56046d8dd 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -111,8 +111,9 @@ boolean and array subset. Unrequested fields and numeric seller limits do not participate. Forecasts without concrete values describe the product's discovery/default scope; buyers rediscover with concrete `targeting_overlay` values for value-specific forecasts. Fixed prices and floors remain binding -uniformly for supported selections, while price guidance remains non-binding. -Sellers with value-dependent rates return a concretely targeted configured +uniformly for supported selections, except for adjustments the selected fixed-price +option declares in `price_adjustments`; price guidance remains non-binding. +Sellers with other value-dependent rates return a concretely targeted configured product, split rate tiers into products, or withhold binding prices until concrete rediscovery; they never reuse a selected `pricing_option_id` at new terms. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index ec259a94306..3310a4b689d 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -347,8 +347,9 @@ it does not guarantee inventory or forecast every possible value. Forecasts returned when only future support was requested describe the product's discovery/default scope. Rediscover with concrete `targeting_overlay` values for a value-specific forecast. Fixed prices and floors remain binding uniformly -for supported selections; price guidance remains non-binding. Sellers with -value-dependent rates must return a concretely targeted configured product, +for supported selections, except for adjustments the selected fixed-price option +declares in `price_adjustments`; price guidance remains non-binding. Sellers with +other value-dependent rates must return a concretely targeted configured product, split the rate tiers into products, or withhold binding pricing until concrete rediscovery. They cannot reinterpret a selected `pricing_option_id` at a new price. If a supported selection has no current inventory, creation returns diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx index 9367c9c349f..f0f1d7aa4b7 100644 --- a/docs/reference/experimental-status.mdx +++ b/docs/reference/experimental-status.mdx @@ -150,6 +150,7 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c | `account.invoice_lines` | The optional `lines[]`, `invoice_kind` (`enums/invoice-kind.json`), and `adjusts_invoice_id` on `invoices[]` items of [`get_account_financials`](/docs/accounts/tasks/get_account_financials), and the `core/invoice-line.json` shape ([#8184](https://github.com/adcontextprotocol/adcp/issues/8184)) | Ties each invoice amount to the media buys, packages, official reporting revisions, and reporting adjustments it bills, so a buyer can reconcile what it was charged for against what was finalized. Reference only: it never creates, approves, or settles invoices. Single-adopter so far, and the ids it references come from `media_buy.reporting_delivery`. May change: itemised `lines[]` keyed to `reporting_revision_ids`, credit notes as `invoice_kind: credit_note` with a non-negative `amount` rather than a negative amount. The line shape is reusable so a later account-change-feed invoice notification can carry `lines` unchanged. | | `media_buy.installment_selection` | The `installments` array on `media-buy/product-purchase.json` (so on every `commercial_terms.purchases[]` entry of a canonical proposal) and its closed `core/canonical-installment-ref.json` item, the `installment_changes` field on `media-buy/proposal-refinement.json` and its `core/installment-change-set.json` item schema, the `installment_changes` value in `media_buy.proposal_refinement.supported_dimensions`, and `unsatisfied_installment_changes` on the [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals#change-installments-experimental) result ([#8230](https://github.com/adcontextprotocol/adcp/issues/8230)) | Buying and renegotiating named installments rather than whole products, with one named adopter so far: Availnet, a creator marketplace, where the slot in a creator's upcoming video is the thing being sold. The **representation** is not experimental — the canonical Product's `installments[]` and `core/canonical-installment.json` are stable 3.3 additions — only the selection surface is. `terms_digest` binds the slot identifiers bought, not what the seller later publishes under them; whether a `publisher_installment` definition-pin ref_kind should close that is open, as is seller-offered rescheduling ([#8102](https://github.com/adcontextprotocol/adcp/issues/8102)), which may reshape the change verbs or add a `replace` action in place of today's non-atomic omit-plus-include swap. Per-installment hold readback shipped separately as `media_buy.installment_holds`; that array is readback outside `commercial_terms` while this one is contract inside it. This array already fixes what is reserved — a named bought set is reserved exactly — so what is open sits on the readback: whether `holds[]` MUST enumerate that reserved set, and whether its "one that product includes" narrows to "one that purchase bought". Both belong to the `holds` description. Whether an installment-scoped budget or price belongs on the purchase is open; today pricing stays product-scoped. Sellers that emit purchase-level `installments` or accept `installment_changes` declare the id, and MUST NOT emit either to callers negotiating below AdCP 3.3; a buyer that has not implemented the id rejects a proposal carrying purchase `installments` rather than ignoring it. | | `signals.pricing_groups` | The optional `pricing_group` field on the shared signal-listing fields (`get_signals` results and `Product.signal_targeting_options[]`), and `signal_pricing_group_rules[]` (schema: `core/signal-pricing-group-rule.json`) on `signal_targeting_rules` and the `get_signals` response (#7898) | Defines how fees of signals sharing a pricing group combine, a new commercial rule backed so far by one adopter (one adopter applies `highest` today as a platform default). Decided 2026-10-24 for 3.3 beta.1 ([#7898](https://github.com/adcontextprotocol/adcp/issues/7898)): a group with no rule is charged `each`, and `percent_of_media` is always `each` (no conversion rule). The `signal_charges[]` echo of per-signal charges on package state is a follow-up, so buyers reconcile against the published rules for now. | +| `media_buy.price_adjustments` | The `price_adjustments` table on fixed-price pricing options (not embedded in accepted purchase `pricing`: the accepted `pricing_option_id` pins one immutable table, and embedding it is a 4.0 item, #8501), its fee and discount rows (applied in declared order to one running total) and validity windows, and `error-details/price-adjustments-mismatch.json` ([selection-dependent price adjustments](/docs/media-buy/advanced-topics/pricing-models#selection-dependent-price-adjustments)) | One named pilot (Dutch linear TV rate cards) and one RFC, with the field set still growing: position in break, audience, activation method, and measurement vendor have no selectable anchor yet, a dimension can fire only one row in 3.3, and per-installment resolution of a flight that crosses a validity window is open. A seller must not return a table to callers below 3.3. | Graduation progress and notice of upcoming breaking changes will be called out in the [release notes](/docs/reference/release-notes) for each 3.x release starting with 3.0 GA. diff --git a/docs/reference/test-vectors/index.mdx b/docs/reference/test-vectors/index.mdx index f6387333395..adc39e64049 100644 --- a/docs/reference/test-vectors/index.mdx +++ b/docs/reference/test-vectors/index.mdx @@ -52,6 +52,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t | [`oauth-setup`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/oauth-setup) | OAuth metadata-graph conformance: deterministic network fixtures for `oauth_metadata_graph` checks, redirect validation, graph-budget enforcement, and fail-closed behavior for missing fixture responses. No live network access permitted | `static/compliance/source/test-vectors/oauth-setup/` | `/compliance/latest/test-vectors/oauth-setup/` | | [`trusted-match-context-merge`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/trusted-match-context-merge) | Context Match targeting key-value merge: router attribution from publisher-controlled provider registration, bucket-key derivation, provider `signals.targeting_kvs` pass-through, shared-key multi-provider contribution, and dropped unmapped/case-mismatched tuples | `static/compliance/source/test-vectors/trusted-match-context-merge/` | `/compliance/latest/test-vectors/trusted-match-context-merge/` | | [`products-only-brief-compatibility`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/products-only-brief-compatibility) | Products-only brief flow across AdCP 2.5, 3.0, 3.1: compact projection without invented proposals, fail-closed legacy-create continuation with named fences, seller-fenced `listed_purchase` feed/pricing propagation, and 3.2 reverse-compatibility facades | `static/compliance/source/test-vectors/products-only-brief-compatibility/` | `/compliance/latest/test-vectors/products-only-brief-compatibility/` | +| [`price-adjustments`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/price-adjustments) | Deterministic resolution of selection-dependent `price_adjustments`: one row per dimension (first match), sequential running total in declared order (index cards as fee and discount rates), quote binding to account and expiry, accepted terms pricing a permitted change without granting one, half-away-from-zero rounding at option price precision (JPY, sub-cent `cpv`), declared-breakdown composition, cross-encoding and cross-scope selector identity, below-zero rejection, buyer-pricing mismatch, new-ID-on-change, and schema negatives (next 3.3 beta) | `static/compliance/source/test-vectors/price-adjustments/` | `/compliance/latest/test-vectors/price-adjustments/` | | [`error-recovery`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/error-recovery) | Version-gated 3.2 recovery emission and compatible 3.1 decoding: known/unknown codes with and without `recovery`, buyer-reason consistency, bounded fallback retries, and `retry_after` scheduling including legacy fractions | `static/compliance/source/test-vectors/error-recovery/` | `/compliance/latest/test-vectors/error-recovery/` | | [`reporting-reconciliation`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/reporting-reconciliation) | Managed-reporting reconciliation: exact manifest/object/receipt bytes, canonical logical rows, SHA-256 and SHA-512 evidence, retry/permanent classifications, integrity mutations, receipt idempotency, and uncertain-write checkpoint recovery | `static/compliance/source/test-vectors/reporting-reconciliation/` | `/compliance/latest/test-vectors/reporting-reconciliation/` | | [`relationship-scoped-indicators`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/relationship-scoped-indicators.json) | Indicator snapshot semantics and invalidation lifecycle: scope containment validation, snapshot freshness, and relationship-scoped invalidation behavior | `static/compliance/source/test-vectors/relationship-scoped-indicators.json` | `/compliance/latest/test-vectors/relationship-scoped-indicators.json` | diff --git a/package.json b/package.json index f27cce289ca..0bbdcf6a85c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "verify:schema-discovery": "node scripts/verify-schema-discovery.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/structured-package-refine.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/purchase-rights.test.cjs tests/account-requests.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/price-adjustments-vectors.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/structured-package-refine.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/purchase-rights.test.cjs tests/account-requests.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/scripts/oneof-discriminators.baseline.json b/scripts/oneof-discriminators.baseline.json index eb9cb7cbb08..804221c1901 100644 --- a/scripts/oneof-discriminators.baseline.json +++ b/scripts/oneof-discriminators.baseline.json @@ -331,6 +331,21 @@ "variants": 2, "note": "shared-required=[∅]; 0:number | 1:req=[duration_seconds]" }, + "pricing-options/price-adjustments.json##/items/oneOf/0/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[rate] | 1:[amount]" + }, + "pricing-options/price-adjustments.json##/items/oneOf/1/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[rate] | 1:[amount]" + }, + "pricing-options/price-adjustments.json##/items/oneOf/2/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[rate] | 1:[amount]" + }, "pricing-options/price-breakdown.json##/properties/adjustments/items/oneOf": { "kind": "narrowable", "variants": 2, diff --git a/static/compliance/source/test-vectors/price-adjustments/README.md b/static/compliance/source/test-vectors/price-adjustments/README.md new file mode 100644 index 00000000000..3ff59adf194 --- /dev/null +++ b/static/compliance/source/test-vectors/price-adjustments/README.md @@ -0,0 +1,54 @@ +# Price adjustment resolution vectors + +These vectors pin the deterministic resolution rule for `price_adjustments` on +a binding fixed-price pricing option. A table is optional: a fully priced offer +is an equally supported path, and its price applies only to what was quoted. + +1. **One running total, declared order.** Starting at `fixed_price`, each + firing row applies to the running total in the seller's declared order. A + `rate` row adds (`fee`) or subtracts (`discount`) `round(running x rate)`; an + `amount` row adds or subtracts its amount, and later rate rows apply to the + total including it. A 10% fee then a 10% fee is x1.21, not x1.20. +2. **Index cards are rates.** There is no separate index row kind: a factor + `f > 1` is a fee `rate` of `f - 1` and a factor `f < 1` is a discount `rate` + of `1 - f`, applied in the same sequence. A fee `rate` may be 1 or more + (factor 2.5 is a fee rate of 1.5). +3. Selection rows (`format_option`, `placement_selection`) fire at most once per + dimension: the first applicable row in declared order. `always` rows each + fire. Matching is full-identity equality after resolution. +4. `valid_from` (inclusive) / `valid_until` (exclusive) bound a row to a window + evaluated at the purchase `start_time`. A flight that crosses a window + boundary of a row that would otherwise apply is rejected with + `INVALID_REQUEST`; a flight may end exactly on a `valid_until`. +5. Every delta rounds half away from zero to the option's price precision: the + greatest of the currency minor-unit exponent, 4 for + `cpm`/`vcpm`/`cpc`/`cpcv`/`cpv`/`cpp`, and the fewest decimal places that + exactly represent `fixed_price` and each amount, by numeric value. The + resolved price is the exact running total and never falls below zero. +6. Omitted or default selections match no selection row and pay the base rate. +7. A buyer-supplied `pricing` that differs from the resolved result is rejected + with `INVALID_REQUEST` carrying the resolved price and breakdown; a changed + table requires a new `pricing_option_id`. +8. An accepted snapshot carries the resolved `fixed_price` and a + `price_breakdown` of the declared adjustments plus each fired row in declared + order (a rate below 1 as its rate, otherwise as its currency amount), both + existing fields, so a verifier can re-resolve it. The table is not embedded + in the snapshot (the canonical schemas are closed; embedding is a 4.0 item, + #8501). +9. **Quotes stay bound to their terms** (`quote_binding_cases`). A table applies + only to the account it was returned for and expires with the quote: the + proposal or offer `expires_at`, or the discovery response's validity. +10. **Accepted terms** (`accepted_terms_cases`). A changed table is a new + `pricing_option_id`, so the accepted `pricing_option_id` (covered by + `terms_digest`) identifies exactly one immutable table, which both parties + retain from discovery. A change the agreement already permits is priced from + that table, never from a later table, without a re-quote. The table grants no + change right: a change not permitted by `change_terms[]` is + `ACTION_NOT_ALLOWED` whatever the table says. + +The `dutch_tv_*` cases are a fictional linear TV rate card: a net base price +multiplied by month, market, spot-length and target-audience indices (written as +fee and discount rates), plus a first-in-break premium. Resolvers MUST use exact +decimal or integer arithmetic; the tie cases fail under binary floating point or +currency-only rounding. SDK suites consume this file to check their own +resolvers. diff --git a/static/compliance/source/test-vectors/price-adjustments/vectors.json b/static/compliance/source/test-vectors/price-adjustments/vectors.json new file mode 100644 index 00000000000..c73649defff --- /dev/null +++ b/static/compliance/source/test-vectors/price-adjustments/vectors.json @@ -0,0 +1,1809 @@ +{ + "description": "Deterministic resolution vectors for price_adjustments on binding fixed-price pricing options. One running total: starting at fixed_price, each firing row applies to it in declared order (a rate row adds or subtracts round half away from zero of running x rate, an amount row adds or subtracts its amount), so a 10% fee then a 10% fee is x1.21. Dutch index cards are expressed as fee or discount rates (factor 1.25 is a fee rate of 0.25, factor 0.80 a discount rate of 0.20). Selection rows fire at most once per dimension (the first valid matching row in declared order); always rows fire independently; valid_from/valid_until bound a row to a half-open window evaluated at the purchase start_time, and a flight that crosses a window boundary of a row that would otherwise apply is rejected. Price precision is the greatest of the currency minor-unit exponent, 4 for cpm/vcpm/cpc/cpcv/cpv/cpp, and the fewest decimals that exactly represent fixed_price and each amount, by numeric value; the resolved price is the exact running total and never below zero. Arithmetic MUST be exact decimal or integer. Each fired row is recorded in price_breakdown in declared order: a rate below 1 as its rate, otherwise (a fee rate of 1 or more, or an amount row) as its currency amount. A table is bound to its quote: it applies only to the account it was returned for and until the quote expires. A changed table is a new pricing_option_id, so the accepted pricing_option_id identifies exactly one immutable table, which both parties retain from discovery; a change the agreement already permits is priced from that table, never a later one, and the table grants no change right.", + "currency_minor_unit_exponent": { + "USD": 2, + "JPY": 0, + "EUR": 2 + }, + "options": { + "cpm_rate_card": { + "pricing_option_id": "cpm_usd_rate_card_v1", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_sports" + }, + "kind": "fee", + "name": "premium_sports_placement", + "rate": 0.1 + } + ] + }, + "cpm_placement_first": { + "pricing_option_id": "cpm_usd_rate_card_v2", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + }, + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + }, + "cpm_two_dimension_rates": { + "pricing_option_id": "cpm_usd_two_rates", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 10.0, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "rate": 0.1 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.1 + } + ] + }, + "cpm_rounding": { + "pricing_option_id": "cpm_usd_rounding", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 1.15, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "rate": 0.001 + } + ] + }, + "cpm_deep_discount": { + "pricing_option_id": "cpm_usd_deep_discount", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 1.0, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "discount", + "name": "launch_credit", + "amount": 2.0 + } + ] + }, + "cpm_declared_breakdown": { + "pricing_option_id": "cpm_usd_negotiated_v1", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_breakdown": { + "list_price": 25.0, + "adjustments": [ + { + "kind": "discount", + "name": "volume", + "rate": 0.2 + } + ] + }, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + }, + "cpm_publisher_format": { + "pricing_option_id": "cpm_usd_publisher_format", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 10.0, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "publisher", + "publisher_domain": "streamhaus.example", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "publisher_video_30s", + "amount": 1.0 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "amount": 0.5 + } + ] + }, + "cpv_sub_cent": { + "pricing_option_id": "cpv_usd_sub_cent", + "pricing_model": "cpv", + "currency": "USD", + "fixed_price": 0.0125, + "parameters": { + "view_threshold": 0.5 + }, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "rate": 0.05 + } + ] + }, + "flat_jpy": { + "pricing_option_id": "flat_jpy_rate_card", + "pricing_model": "flat_rate", + "currency": "JPY", + "fixed_price": 1000, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "rate": 0.0005 + } + ] + }, + "cpv_five_decimals": { + "pricing_option_id": "cpv_usd_five_decimals", + "pricing_model": "cpv", + "currency": "USD", + "fixed_price": 0.00125, + "parameters": { + "view_threshold": 0.5 + }, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "rate": 0.1 + } + ] + }, + "tv_grp_rate_card": { + "pricing_option_id": "oranje_grp_eur_2026", + "pricing_model": "cpp", + "currency": "EUR", + "fixed_price": 1000, + "parameters": { + "demographic": "A25-54" + }, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "month_index_nov", + "rate": 0.1, + "valid_from": "2026-11-01T00:00:00+01:00", + "valid_until": "2026-12-01T00:00:00+01:00" + }, + { + "dimension": "always", + "kind": "discount", + "name": "market_index_nov", + "rate": 0.05, + "valid_from": "2026-11-01T00:00:00+01:00", + "valid_until": "2026-12-01T00:00:00+01:00" + }, + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "spot_20s" + }, + "kind": "discount", + "name": "spot_length_20s", + "rate": 0.2 + }, + { + "dimension": "always", + "kind": "fee", + "name": "target_audience_a25_54", + "rate": 0.25 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "oranjetv.example", + "placement_id": "first_in_break" + }, + "kind": "fee", + "name": "first_in_break_premium", + "rate": 0.2 + } + ] + }, + "cpm_index_then_premium": { + "pricing_option_id": "cpm_usd_index_premium", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "season_index", + "rate": 0.1 + }, + { + "dimension": "placement_selection", + "placement_ref": { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + }, + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + }, + "cpm_two_indices": { + "pricing_option_id": "cpm_usd_two_indices", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "season_index", + "rate": 0.1 + }, + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "length_index", + "rate": 0.2 + } + ] + }, + "cpm_index_rounding": { + "pricing_option_id": "cpm_usd_index_rounding", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 1.2345, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "season_index", + "rate": 0.1 + } + ] + }, + "cpm_index_below_one": { + "pricing_option_id": "cpm_usd_index_below_one", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "always", + "kind": "discount", + "name": "off_peak_index", + "rate": 0.15 + } + ] + }, + "cpm_windowed_alternates": { + "pricing_option_id": "cpm_usd_windowed_alternates", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "length_index_nov", + "rate": 0.1, + "valid_from": "2026-11-01T00:00:00+01:00", + "valid_until": "2026-12-01T00:00:00+01:00" + }, + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "length_index_dec", + "rate": 0.2, + "valid_from": "2026-12-01T00:00:00+01:00", + "valid_until": "2027-01-01T00:00:00+01:00" + } + ] + }, + "cpm_prime_index": { + "pricing_option_id": "cpm_usd_prime_index", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "prime_position_index", + "rate": 1.5 + } + ] + }, + "cpm_two_fees": { + "pricing_option_id": "cpm_usd_two_fees", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 100.0, + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "platform_fee", + "rate": 0.1 + }, + { + "dimension": "always", + "kind": "fee", + "name": "cover_fee", + "rate": 0.1 + } + ] + }, + "cpm_seller_inline_placement": { + "pricing_option_id": "cpm_usd_seller_inline", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 20.0, + "price_adjustments": [ + { + "dimension": "placement_selection", + "placement_ref": { + "kind": "seller_inline", + "seller_agent": { + "agent_url": "https://Agent.Example:443/" + }, + "placement_id": "premium_home" + }, + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + "resolution_cases": [ + { + "id": "one_adjustment", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 22.0, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + } + }, + { + "id": "amount_then_rate_applies_to_running_total", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 25.3, + "applied": [ + "video_30s_format", + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + }, + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + { + "id": "declared_order_changes_the_price", + "option": "cpm_placement_first", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 25.0, + "applied": [ + "premium_home_placement", + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + }, + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + } + }, + { + "id": "unmatched_selection_pays_base_rate", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "display_300x250" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "run_of_site" + } + ] + } + }, + "expect": { + "fixed_price": 20.0, + "applied": [], + "breakdown": [] + } + }, + { + "id": "default_selection_matches_no_row", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 20.0, + "applied": [], + "breakdown": [] + } + }, + { + "id": "multi_value_dimension_fires_first_matching_row_once", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_sports" + }, + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 23.0, + "applied": [ + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + { + "id": "two_ten_percent_fees_compound_to_twenty_one_percent", + "option": "cpm_two_fees", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 121.0, + "applied": [ + "platform_fee", + "cover_fee" + ], + "breakdown": [ + { + "kind": "fee", + "name": "platform_fee", + "rate": 0.1 + }, + { + "kind": "fee", + "name": "cover_fee", + "rate": 0.1 + } + ] + } + }, + { + "id": "rates_across_dimensions_compound", + "option": "cpm_two_dimension_rates", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 12.1, + "applied": [ + "video_30s_format", + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "rate": 0.1 + }, + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.1 + } + ] + } + }, + { + "id": "rate_delta_rounds_half_away_from_zero_to_price_precision", + "option": "cpm_rounding", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 1.1512, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "rate": 0.001 + } + ] + } + }, + { + "id": "surcharges_apply_to_net_of_declared_breakdown", + "option": "cpm_declared_breakdown", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 25.3, + "applied": [ + "video_30s_format", + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + }, + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + { + "id": "declared_breakdown_with_amount_row_only", + "option": "cpm_declared_breakdown", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 22.0, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + } + }, + { + "id": "sub_cent_unit_price_keeps_four_decimals", + "option": "cpv_sub_cent", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 0.0131, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "rate": 0.05 + } + ] + } + }, + { + "id": "declared_five_decimals_set_price_precision", + "option": "cpv_five_decimals", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 0.00138, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "rate": 0.1 + } + ] + } + }, + { + "id": "flat_rate_zero_exponent_currency_rounds_to_whole_units", + "option": "flat_jpy", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 1001, + "applied": [ + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "rate": 0.0005 + } + ] + } + }, + { + "id": "legacy_and_identity_placement_encodings_match", + "option": "cpm_publisher_format", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "kind": "publisher_ref", + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 10.5, + "applied": [ + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "premium_home_placement", + "amount": 0.5 + } + ] + } + }, + { + "id": "seller_inline_agent_url_matches_under_url_canonicalization", + "option": "cpm_seller_inline_placement", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "kind": "seller_inline", + "seller_agent": { + "agent_url": "https://agent.example/" + }, + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 23.0, + "applied": [ + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + { + "id": "seller_inline_different_agent_does_not_match", + "option": "cpm_seller_inline_placement", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "kind": "seller_inline", + "seller_agent": { + "agent_url": "https://other.example/" + }, + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 20.0, + "applied": [], + "breakdown": [] + } + }, + { + "id": "same_format_id_under_different_scope_does_not_match", + "option": "cpm_publisher_format", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 10.0, + "applied": [], + "breakdown": [] + } + }, + { + "id": "publisher_scoped_format_matches_publisher_scoped_row", + "option": "cpm_publisher_format", + "selection": { + "format_option_refs": [ + { + "scope": "publisher", + "publisher_domain": "streamhaus.example", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 11.0, + "applied": [ + "publisher_video_30s" + ], + "breakdown": [ + { + "kind": "fee", + "name": "publisher_video_30s", + "amount": 1.0 + } + ] + } + }, + { + "id": "dutch_tv_index_stack_then_first_in_break_premium", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "spot_20s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "oranjetv.example", + "placement_id": "first_in_break" + } + ] + }, + "flight": { + "start_time": "2026-11-09T00:00:00+01:00", + "end_time": "2026-11-22T00:00:00+01:00" + } + }, + "expect": { + "fixed_price": 1254.0, + "applied": [ + "month_index_nov", + "market_index_nov", + "spot_length_20s", + "target_audience_a25_54", + "first_in_break_premium" + ], + "breakdown": [ + { + "kind": "fee", + "name": "month_index_nov", + "rate": 0.1 + }, + { + "kind": "discount", + "name": "market_index_nov", + "rate": 0.05 + }, + { + "kind": "discount", + "name": "spot_length_20s", + "rate": 0.2 + }, + { + "kind": "fee", + "name": "target_audience_a25_54", + "rate": 0.25 + }, + { + "kind": "fee", + "name": "first_in_break_premium", + "rate": 0.2 + } + ] + } + }, + { + "id": "dutch_tv_month_windows_do_not_apply_in_december", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "spot_20s" + } + ], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-12-07T00:00:00+01:00", + "end_time": "2026-12-20T00:00:00+01:00" + } + }, + "expect": { + "fixed_price": 1000.0, + "applied": [ + "spot_length_20s", + "target_audience_a25_54" + ], + "breakdown": [ + { + "kind": "discount", + "name": "spot_length_20s", + "rate": 0.2 + }, + { + "kind": "fee", + "name": "target_audience_a25_54", + "rate": 0.25 + } + ] + } + }, + { + "id": "window_end_is_exclusive_and_flight_may_end_on_it", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "spot_20s" + } + ], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-11-23T00:00:00+01:00", + "end_time": "2026-12-01T00:00:00+01:00" + } + }, + "expect": { + "fixed_price": 1045.0, + "applied": [ + "month_index_nov", + "market_index_nov", + "spot_length_20s", + "target_audience_a25_54" + ], + "breakdown": [ + { + "kind": "fee", + "name": "month_index_nov", + "rate": 0.1 + }, + { + "kind": "discount", + "name": "market_index_nov", + "rate": 0.05 + }, + { + "kind": "discount", + "name": "spot_length_20s", + "rate": 0.2 + }, + { + "kind": "fee", + "name": "target_audience_a25_54", + "rate": 0.25 + } + ] + } + }, + { + "id": "window_start_at_valid_until_does_not_fire", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-12-01T00:00:00+01:00", + "end_time": "2026-12-14T00:00:00+01:00" + } + }, + "expect": { + "fixed_price": 1250.0, + "applied": [ + "target_audience_a25_54" + ], + "breakdown": [ + { + "kind": "fee", + "name": "target_audience_a25_54", + "rate": 0.25 + } + ] + } + }, + { + "id": "later_rate_applies_to_the_running_total_not_the_base", + "option": "cpm_index_then_premium", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "expect": { + "fixed_price": 25.3, + "applied": [ + "season_index", + "premium_home_placement" + ], + "breakdown": [ + { + "kind": "fee", + "name": "season_index", + "rate": 0.1 + }, + { + "kind": "fee", + "name": "premium_home_placement", + "rate": 0.15 + } + ] + } + }, + { + "id": "amount_after_a_rate_is_not_scaled_by_it", + "option": "cpm_index_then_premium", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 24.0, + "applied": [ + "season_index", + "video_30s_format" + ], + "breakdown": [ + { + "kind": "fee", + "name": "season_index", + "rate": 0.1 + }, + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + } + }, + { + "id": "index_card_rows_compound_in_declared_order", + "option": "cpm_two_indices", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 26.4, + "applied": [ + "season_index", + "length_index" + ], + "breakdown": [ + { + "kind": "fee", + "name": "season_index", + "rate": 0.1 + }, + { + "kind": "fee", + "name": "length_index", + "rate": 0.2 + } + ] + } + }, + { + "id": "index_card_row_rounds_half_away_from_zero_at_price_precision", + "option": "cpm_index_rounding", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 1.358, + "applied": [ + "season_index" + ], + "breakdown": [ + { + "kind": "fee", + "name": "season_index", + "rate": 0.1 + } + ] + } + }, + { + "id": "index_factor_below_one_is_a_discount_rate", + "option": "cpm_index_below_one", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 17.0, + "applied": [ + "off_peak_index" + ], + "breakdown": [ + { + "kind": "discount", + "name": "off_peak_index", + "rate": 0.15 + } + ] + } + }, + { + "id": "first_valid_row_per_dimension_picks_the_window", + "option": "cpm_windowed_alternates", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-12-07T00:00:00+01:00", + "end_time": "2026-12-20T00:00:00+01:00" + } + }, + "expect": { + "fixed_price": 24.0, + "applied": [ + "length_index_dec" + ], + "breakdown": [ + { + "kind": "fee", + "name": "length_index_dec", + "rate": 0.2 + } + ] + } + }, + { + "id": "fee_rate_of_one_or_more_is_recorded_as_its_currency_amount", + "option": "cpm_prime_index", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "expect": { + "fixed_price": 50.0, + "applied": [ + "prime_position_index" + ], + "breakdown": [ + { + "kind": "fee", + "name": "prime_position_index", + "amount": 30.0 + } + ] + } + } + ], + "invalid_resolution_cases": [ + { + "id": "discount_below_zero", + "option": "cpm_deep_discount", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "expect_error": "below_zero", + "expect_outcome": "INVALID_REQUEST" + }, + { + "id": "flight_crossing_a_window_boundary_is_rejected", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "spot_20s" + } + ], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-11-25T00:00:00+01:00", + "end_time": "2026-12-05T00:00:00+01:00" + } + }, + "expect_error": "flight_spans_validity_boundary", + "expect_outcome": "INVALID_REQUEST" + }, + { + "id": "flight_starting_before_a_window_and_ending_inside_it_is_rejected", + "option": "tv_grp_rate_card", + "selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + }, + "flight": { + "start_time": "2026-10-28T00:00:00+01:00", + "end_time": "2026-11-05T00:00:00+01:00" + } + }, + "expect_error": "flight_spans_validity_boundary", + "expect_outcome": "INVALID_REQUEST" + } + ], + "buyer_supplied_pricing_cases": [ + { + "id": "matching_buyer_pricing_accepted", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "supplied_fixed_price": 22.0, + "expect": "accepted" + }, + { + "id": "base_rate_supplied_for_priced_selection_rejected", + "option": "cpm_rate_card", + "selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "supplied_fixed_price": 20.0, + "expect": "INVALID_REQUEST", + "error_field": "pricing.fixed_price", + "resolved_fixed_price": 22.0, + "resolved_breakdown": [ + { + "kind": "fee", + "name": "video_30s_format", + "amount": 2.0 + } + ] + } + ], + "identity_cases": [ + { + "id": "changed_table_requires_new_pricing_option_id", + "previous": "cpm_rate_card", + "current": "cpm_placement_first", + "expect": "distinct_pricing_option_ids" + } + ], + "schema_negative_cases": [ + { + "id": "rate_and_amount_together", + "mutate": { + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "fee", + "name": "both", + "rate": 0.1, + "amount": 1.0 + } + ] + } + }, + { + "id": "commission_kind_not_selection_dependent", + "mutate": { + "price_adjustments": [ + { + "dimension": "format_option", + "format_option_ref": { + "scope": "product", + "format_option_id": "video_30s" + }, + "kind": "commission", + "name": "agency", + "rate": 0.1 + } + ] + } + }, + { + "id": "property_list_dimension_not_priceable_in_v1", + "mutate": { + "price_adjustments": [ + { + "dimension": "property_list", + "kind": "fee", + "name": "premium_properties", + "rate": 0.1 + } + ] + } + }, + { + "id": "adjustments_require_fixed_price", + "mutate": { + "fixed_price": null, + "floor_price": 5.0 + } + }, + { + "id": "index_kind_is_not_defined", + "mutate": { + "price_adjustments": [ + { + "dimension": "always", + "kind": "index", + "name": "x", + "factor": 1.1 + } + ] + } + }, + { + "id": "validity_window_must_be_a_date_time", + "mutate": { + "price_adjustments": [ + { + "dimension": "always", + "kind": "index", + "name": "x", + "factor": 1.1, + "valid_from": "November" + } + ] + } + }, + { + "id": "factor_is_not_a_row_field", + "mutate": { + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "x", + "factor": 1.1 + } + ] + } + }, + { + "id": "rate_must_be_positive", + "mutate": { + "price_adjustments": [ + { + "dimension": "always", + "kind": "fee", + "name": "x", + "rate": 0 + } + ] + } + }, + { + "id": "collection_list_dimension_not_priceable_in_v1", + "mutate": { + "price_adjustments": [ + { + "dimension": "collection_list", + "kind": "fee", + "name": "premium_collections", + "rate": 0.1 + } + ] + } + } + ], + "quote_binding_cases": [ + { + "id": "table_applies_to_the_quoted_account_before_expiry", + "quote": { + "account_id": "acct_nova", + "expires_at": "2026-11-01T00:00:00Z" + }, + "use": { + "account_id": "acct_nova", + "at": "2026-10-31T23:59:59Z" + }, + "expect": "table_applies" + }, + { + "id": "table_does_not_apply_to_another_account", + "quote": { + "account_id": "acct_nova", + "expires_at": "2026-11-01T00:00:00Z" + }, + "use": { + "account_id": "acct_pinnacle", + "at": "2026-10-30T00:00:00Z" + }, + "expect": "table_does_not_apply" + }, + { + "id": "table_expires_with_the_quote", + "quote": { + "account_id": "acct_nova", + "expires_at": "2026-11-01T00:00:00Z" + }, + "use": { + "account_id": "acct_nova", + "at": "2026-11-01T00:00:00Z" + }, + "expect": "table_does_not_apply" + } + ], + "accepted_terms_cases": [ + { + "id": "permitted_change_is_priced_from_the_accepted_options_table_without_a_requote", + "option": "cpm_rate_card", + "accepted_selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "requested_selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "change_permitted_by_change_terms": true, + "expect": { + "outcome": "priced", + "fixed_price": 23.0 + } + }, + { + "id": "table_grants_no_change_right", + "option": "cpm_rate_card", + "accepted_selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "requested_selection": { + "format_option_refs": [], + "placement_selection": { + "mode": "default" + } + }, + "change_permitted_by_change_terms": false, + "expect": { + "outcome": "ACTION_NOT_ALLOWED" + } + }, + { + "id": "later_table_does_not_reprice_the_accepted_option", + "option": "cpm_rate_card", + "later_option": "cpm_placement_first", + "accepted_selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "default" + } + }, + "requested_selection": { + "format_option_refs": [ + { + "scope": "product", + "format_option_id": "video_30s" + } + ], + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "streamhaus.example", + "placement_id": "premium_home" + } + ] + } + }, + "change_permitted_by_change_terms": true, + "expect": { + "outcome": "priced", + "fixed_price": 25.3 + } + } + ] +} diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index a64213afc35..f94350b4119 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -117,7 +117,7 @@ }, "pricing_options": { "type": "array", - "description": "Available pricing models for this product. Fixed prices and auction floors are binding for every later targeting selection permitted by this product's overlay_support; price_guidance remains non-binding. Declaring broad overlay support alongside a binding option is therefore a uniform-price promise, not permission to calculate a different price at create time. A seller with value-dependent rates MUST return a request-specific configured product after rediscovery with concrete targeting, split the inventory into separately priced products, or expose only non-binding guidance until it can issue a binding option. The seller MUST mint a new pricing_option_id whenever a binding price, floor, currency, model, or priced applicability changes. It MUST NOT silently reprice a create request or reuse the selected option ID with different terms.", + "description": "Available pricing models for this product. Fixed prices and auction floors are binding for every later targeting selection permitted by this product's overlay_support; price_guidance remains non-binding. Declaring broad overlay support alongside a binding option is therefore a uniform-price promise, not permission to calculate a different price at create time, except for adjustments the selected fixed-price option itself declares in price_adjustments: those rows are part of the option's binding terms, and the resolved rate follows from the buyer's selections by the deterministic rules in price-adjustments.json. A price_adjustments table is optional and has equal standing with a fully priced offer: a seller that does not publish one (for example a yield-managed seller) returns priced options, whose price applies only to what was quoted, and is not expected to publish its method. A seller with other value-dependent rates MUST return a request-specific configured product after rediscovery with concrete targeting, split the inventory into separately priced products, or expose only non-binding guidance until it can issue a binding option. The seller MUST mint a new pricing_option_id whenever a binding price, floor, currency, model, or priced applicability changes. It MUST NOT silently reprice a create request or reuse the selected option ID with different terms.", "items": { "$ref": "/schemas/core/pricing-option.json" }, @@ -245,7 +245,7 @@ }, "overlay_support": { "$ref": "/schemas/core/targeting-overlay-support.json", - "description": "Binding product-scoped targeting dimensions the buyer may set independently on packages after discovery. Presence guarantees selectable capability subject to disclosed limits, not inventory or a forecast for every possible value. Targeting satisfied only through inherent product scope does not appear here. Returned products MUST cover every field requested through get_products.required_overlay_support. A later supported selection with no available inventory returns PRODUCT_UNAVAILABLE on create; an update outside the original priced envelope may return REQUOTE_REQUIRED." + "description": "Binding product-scoped targeting dimensions the buyer may set independently on packages after discovery. Presence guarantees selectable capability subject to disclosed limits, not inventory or a forecast for every possible value. Targeting satisfied only through inherent product scope does not appear here. Returned products MUST cover every field requested through get_products.required_overlay_support. Selecting a value priced by the chosen option's price_adjustments (currently placement_selection and format option selection) changes the resolved rate only as that table declares. A later supported selection with no available inventory returns PRODUCT_UNAVAILABLE on create; an update outside the original priced envelope may return REQUOTE_REQUIRED." }, "media_buy_support": { "$ref": "/schemas/core/media-buy-support.json", @@ -836,6 +836,32 @@ "required": ["property_targeting_allowed"] } }, + { + "if": { + "properties": { + "pricing_options": { + "contains": { + "properties": { + "price_adjustments": { + "contains": { + "properties": { "dimension": { "const": "placement_selection" } }, + "required": ["dimension"] + } + } + }, + "required": ["price_adjustments"] + } + } + }, + "required": ["pricing_options"] + }, + "then": { + "properties": { + "overlay_support": { "required": ["placement_selection"] } + }, + "required": ["overlay_support"] + } + }, { "description": "Selectable placement products cannot also contain unavoidable included placements because placement_selection is the complete purchased set.", "if": { diff --git a/static/schemas/source/core/targeting-overlay-support.json b/static/schemas/source/core/targeting-overlay-support.json index dc2baf7d318..50795edbe62 100644 --- a/static/schemas/source/core/targeting-overlay-support.json +++ b/static/schemas/source/core/targeting-overlay-support.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/targeting-overlay-support.json", "title": "Targeting Overlay Support", - "description": "Product-scoped package targeting dimensions that may be supplied or changed after discovery. This is the seller response shape and may disclose seller limits such as max_values_per_package, max_packages, and max_references. Product.overlay_support is the binding selectable-targeting contract: presence means the seller can apply protocol-valid values within the declared systems, countries, values, types, versions, and limits. Inherent product coverage alone does not satisfy a future-support requirement. Support does not guarantee inventory or a value-specific forecast before values are supplied; fixed prices and floors remain binding for supported selections. geo_regions and geo_regions_exclude are independent: structured support either exhaustively declares every value active in the seller's support snapshot for a country or lists the exact finite selectable subset. Buyer minimums use targeting-overlay-requirements.json. A requirement value of true matches true or any valid support object; an object requirement matches true or a containing support object. The daypart_targets boolean is the backward-compatible exception: true promises inventory_local and whole-hour granularity only, and structured support opts into IANA zones and finer time_granularity. Every valid structured support object represents a positive capability; empty, extension-only, and false-only objects are invalid. Unrequested object fields and numeric seller limits do not participate in matching. A product declares at most one of frequency_cap: true and frequency_cap_support. Legacy frequency_cap: true keeps its broad meaning within seller-wide limits, including update support. A constrained or create-only product instead advertises frequency_cap_support, so older buyers safely treat it as unsupported.", + "description": "Product-scoped package targeting dimensions that may be supplied or changed after discovery. This is the seller response shape and may disclose seller limits such as max_values_per_package, max_packages, and max_references. Product.overlay_support is the binding selectable-targeting contract: presence means the seller can apply protocol-valid values within the declared systems, countries, values, types, versions, and limits. Inherent product coverage alone does not satisfy a future-support requirement. Support does not guarantee inventory or a value-specific forecast before values are supplied; fixed prices and floors remain binding for supported selections, uniform across values except for adjustments declared by the selected fixed-price pricing option's price_adjustments, which price format option and placement_selection selections and dated windows. geo_regions and geo_regions_exclude are independent: structured support either exhaustively declares every value active in the seller's support snapshot for a country or lists the exact finite selectable subset. Buyer minimums use targeting-overlay-requirements.json. A requirement value of true matches true or any valid support object; an object requirement matches true or a containing support object. The daypart_targets boolean is the backward-compatible exception: true promises inventory_local and whole-hour granularity only, and structured support opts into IANA zones and finer time_granularity. Every valid structured support object represents a positive capability; empty, extension-only, and false-only objects are invalid. Unrequested object fields and numeric seller limits do not participate in matching. A product declares at most one of frequency_cap: true and frequency_cap_support. Legacy frequency_cap: true keeps its broad meaning within seller-wide limits, including update support. A constrained or create-only product instead advertises frequency_cap_support, so older buyers safely treat it as unsupported.", "type": "object", "definitions": { "supported": { diff --git a/static/schemas/source/error-details/price-adjustments-mismatch.json b/static/schemas/source/error-details/price-adjustments-mismatch.json new file mode 100644 index 00000000000..f08a246fae1 --- /dev/null +++ b/static/schemas/source/error-details/price-adjustments-mismatch.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/error-details/price-adjustments-mismatch.json", + "title": "Price Adjustments Mismatch Details", + "x-added-in": "3.3.0", + "x-status": "experimental", + "description": "Recommended details shape for an INVALID_REQUEST that rejects a buyer-supplied pricing whose fixed_price or price_breakdown differs from the price the selected option's price_adjustments table resolves to for the purchase's selections and flight. Carries the resolved values so the buyer can see which row it disagrees on. Experimental (media_buy.price_adjustments).", + "type": "object", + "properties": { + "resolved_fixed_price": { + "type": "number", + "minimum": 0, + "description": "The price the table resolves to for this purchase." + }, + "resolved_price_breakdown": { + "$ref": "/schemas/pricing-options/price-breakdown.json", + "description": "The resolved breakdown: declared adjustments followed by one entry per fired row." + } + }, + "required": [ + "resolved_fixed_price" + ], + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/product-purchase.json b/static/schemas/source/media-buy/product-purchase.json index e6b8a5cc0ad..3daa13dc8ec 100644 --- a/static/schemas/source/media-buy/product-purchase.json +++ b/static/schemas/source/media-buy/product-purchase.json @@ -7,6 +7,7 @@ "x-adcp-validation": { "verifier_constraints": { "accepted_snapshot_terms": "Inside canonical-proposal.commercial_terms, each purchase MUST carry resolved start_time and end_time and MUST preserve every applicable measurement term and performance standard. Omission of measurement_terms or performance_standards means the accepted offer declared none.", + "price_adjustments_resolution": "When the option identified by pricing_option_id declares price_adjustments, pricing.fixed_price MUST equal the resolution of that option's table over this purchase's format_option_refs, placement selection and flight, and pricing.price_breakdown MUST record the declared adjustments followed by each fired row in declared order (a rate row as its rate when below 1, otherwise and for an amount row as its currency amount). The table itself is not embedded in the snapshot: pricing_option_id, covered by terms_digest, identifies exactly one immutable table.", "pricing_identity": { "pricing_option_id": "equals_pricing.pricing_option_id_when_pricing_present", "on_violation": "reject_before_commitment" diff --git a/static/schemas/source/pricing-options/cpc-option.json b/static/schemas/source/pricing-options/cpc-option.json index cb524f6271d..2b673258c00 100644 --- a/static/schemas/source/pricing-options/cpc-option.json +++ b/static/schemas/source/pricing-options/cpc-option.json @@ -49,6 +49,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -59,5 +60,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/cpcv-option.json b/static/schemas/source/pricing-options/cpcv-option.json index a91958a62e2..6ac5d12983d 100644 --- a/static/schemas/source/pricing-options/cpcv-option.json +++ b/static/schemas/source/pricing-options/cpcv-option.json @@ -49,6 +49,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -59,5 +60,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/cpm-option.json b/static/schemas/source/pricing-options/cpm-option.json index 1e34ccf2508..3ca4f4442e6 100644 --- a/static/schemas/source/pricing-options/cpm-option.json +++ b/static/schemas/source/pricing-options/cpm-option.json @@ -49,6 +49,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -59,5 +60,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/cpp-option.json b/static/schemas/source/pricing-options/cpp-option.json index c73cd1422f0..6a56c8c7e6d 100644 --- a/static/schemas/source/pricing-options/cpp-option.json +++ b/static/schemas/source/pricing-options/cpp-option.json @@ -64,6 +64,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -74,5 +75,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency", "parameters"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/cpv-option.json b/static/schemas/source/pricing-options/cpv-option.json index 7a09fe80a3b..ef41760e8d3 100644 --- a/static/schemas/source/pricing-options/cpv-option.json +++ b/static/schemas/source/pricing-options/cpv-option.json @@ -80,6 +80,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -90,5 +91,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency", "parameters"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/flat-rate-option.json b/static/schemas/source/pricing-options/flat-rate-option.json index 01f761d166d..7fa05502a28 100644 --- a/static/schemas/source/pricing-options/flat-rate-option.json +++ b/static/schemas/source/pricing-options/flat-rate-option.json @@ -110,6 +110,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -120,5 +121,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/static/schemas/source/pricing-options/price-adjustments.json b/static/schemas/source/pricing-options/price-adjustments.json new file mode 100644 index 00000000000..dc999cb77e1 --- /dev/null +++ b/static/schemas/source/pricing-options/price-adjustments.json @@ -0,0 +1,238 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/pricing-options/price-adjustments.json", + "title": "Price Adjustments", + "x-added-in": "3.3.0", + "description": "Optional ordered adjustment table on a binding fixed-price option; fixed_price is the base rate. A table and a fully priced seller offer have equal standing: a table tells the buyer it can compute the price locally for the selections and dates it covers; without one the price applies only to what was quoted, and yield-managed sellers are not expected to publish one. Composition is one running total in declared order: each firing row adds (fee) or subtracts (discount) round(running x rate) or its amount, so a 10% fee then a 10% fee is x1.21. An index factor f is a fee rate f - 1 or a discount rate 1 - f. Selection rows fire once per dimension (first applicable row); always rows each fire; valid_from/valid_until bound a row at the purchase start_time. A table applies only to the account it was returned for and expires with its quote (proposal or offer expires_at, or the discovery response's validity). Any change to the table mints a new pricing_option_id, so the accepted pricing_option_id (covered by terms_digest) identifies exactly one immutable table: changes the agreement already permits are priced from that table, which both parties retain from discovery and the seller MUST price from, not from a later table. The table grants no change rights; those come only from change_terms[]. Sellers MUST NOT return a table to callers below adcp_version 3.3. See pricing-models.mdx#selection-dependent-price-adjustments.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "const": "format_option" + }, + "format_option_ref": { + "$ref": "/schemas/core/format-option-ref.json", + "description": "Matches when the purchase selects it." + }, + "kind": { + "type": "string", + "enum": [ + "fee", + "discount" + ], + "description": "fee adds to the running total, discount subtracts." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "rate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Proportion of the running total at this step; a fee rate may be 1 or more." + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Fixed amount per pricing unit." + }, + "valid_from": { + "type": "string", + "format": "date-time", + "description": "Inclusive window start." + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Exclusive window end." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "dimension", + "format_option_ref", + "kind", + "name" + ], + "oneOf": [ + { + "required": [ + "rate" + ] + }, + { + "required": [ + "amount" + ] + } + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "const": "placement_selection" + }, + "placement_ref": { + "description": "Matches when the purchase selects it.", + "anyOf": [ + { + "allOf": [ + { + "$ref": "/schemas/core/placement-ref.json" + }, + { + "required": [ + "publisher_domain" + ] + } + ] + }, + { + "$ref": "/schemas/core/placement-identity.json" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "fee", + "discount" + ], + "description": "fee adds to the running total, discount subtracts." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "rate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Proportion of the running total at this step; a fee rate may be 1 or more." + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Fixed amount per pricing unit." + }, + "valid_from": { + "type": "string", + "format": "date-time", + "description": "Inclusive window start." + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Exclusive window end." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "dimension", + "placement_ref", + "kind", + "name" + ], + "oneOf": [ + { + "required": [ + "rate" + ] + }, + { + "required": [ + "amount" + ] + } + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "const": "always" + }, + "kind": { + "type": "string", + "enum": [ + "fee", + "discount" + ], + "description": "fee adds to the running total, discount subtracts." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "rate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Proportion of the running total at this step; a fee rate may be 1 or more." + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Fixed amount per pricing unit." + }, + "valid_from": { + "type": "string", + "format": "date-time", + "description": "Inclusive window start." + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Exclusive window end." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "dimension", + "kind", + "name" + ], + "oneOf": [ + { + "required": [ + "rate" + ] + }, + { + "required": [ + "amount" + ] + } + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 20, + "x-adcp-validation": { + "verifier_constraints": { + "composition": "Rows apply sequentially to one running total in declared order; each step rounds half away from zero to price precision.", + "price_precision": "Greatest of the currency minor-unit exponent, 4 for cpm/vcpm/cpc/cpcv/cpv/cpp, and the fewest decimals exactly representing fixed_price and each amount, by numeric value.", + "flight_windows": "A flight crossing a row's valid_from or valid_until is rejected with INVALID_REQUEST.", + "resolved_price_binding": "A buyer-supplied pricing that differs from the resolved result MUST be rejected with INVALID_REQUEST carrying resolved_fixed_price and resolved_price_breakdown." + } + }, + "x-status": "experimental" +} diff --git a/static/schemas/source/pricing-options/price-breakdown.json b/static/schemas/source/pricing-options/price-breakdown.json index 124812b7d38..ab9aeac0842 100644 --- a/static/schemas/source/pricing-options/price-breakdown.json +++ b/static/schemas/source/pricing-options/price-breakdown.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/pricing-options/price-breakdown.json", "title": "Price Breakdown", - "description": "Breaks down how the buyer's effective price is composed from list_price. Adjustment kinds: fee and operator_fee increase the price, discount reduces it, and commission and settlement are disclosed without changing it. Invariant: list_price with fee, discount, and operator_fee adjustments applied in array order, rounded to currency precision at each step, equals fixed_price when the parent object carries it; on auction-based packages the breakdown is informational. Sellers never emit operator_fee. The operator's buying platform adds it after every fee and discount; the running price before the first operator_fee is the seller's price, to which commission and settlement apply. Budgets are denominated at fixed_price, inclusive of commissions; a buying platform's budget toward the advertiser also includes operator fees.", + "description": "Breaks down how the buyer's effective price is composed from list_price. Adjustment kinds: fee and operator_fee increase the price, discount reduces it, and commission and settlement are disclosed without changing it. Invariant: list_price with fee, discount, and operator_fee adjustments applied in array order, rounded to currency precision at each step (or to the option's price precision when it declares price_adjustments, see price-adjustments.json, which can be finer), equals fixed_price when the parent object carries it; on auction-based packages the breakdown is informational. Sellers never emit operator_fee. The operator's buying platform adds it after every fee and discount; the running price before the first operator_fee is the seller's price, to which commission and settlement apply. Budgets are denominated at fixed_price, inclusive of commissions; a buying platform's budget toward the advertiser also includes operator fees.", "type": "object", "properties": { "list_price": { diff --git a/static/schemas/source/pricing-options/vcpm-option.json b/static/schemas/source/pricing-options/vcpm-option.json index ee93ebba4f0..432ddb88305 100644 --- a/static/schemas/source/pricing-options/vcpm-option.json +++ b/static/schemas/source/pricing-options/vcpm-option.json @@ -49,6 +49,7 @@ "description": "Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.", "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "price_adjustments": { "$ref": "/schemas/pricing-options/price-adjustments.json", "x-added-in": "3.3.0", "x-status": "experimental" }, "eligible_adjustments": { "type": "array", "description": "Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.", @@ -59,5 +60,6 @@ } }, "required": ["pricing_option_id", "pricing_model", "currency"], + "dependencies": { "price_adjustments": ["fixed_price"] }, "additionalProperties": true } diff --git a/tests/price-adjustments-vectors.test.cjs b/tests/price-adjustments-vectors.test.cjs new file mode 100644 index 00000000000..76c49f0ad9a --- /dev/null +++ b/tests/price-adjustments-vectors.test.cjs @@ -0,0 +1,392 @@ +/** + * Validates the price_adjustments resolution vectors in + * static/compliance/source/test-vectors/price-adjustments/vectors.json. + * + * Layers: + * 1. Every declared option validates against pricing-option.json (product + * offer). The accepted snapshot is the same option without the table + * (canonical-pricing-option.json is closed, so it carries no table). + * 2. A reference resolver implementing the normative sequential rule (one + * running total, each firing row applied in declared order) reproduces + * every resolved price, ordered application, and fired-row amount in exact + * integer arithmetic at the option's price precision. + * 3. The resolved snapshot (resolved fixed_price, price_breakdown of fired + * rows, no embedded table) validates against canonical-pricing-option.json + * and folds back to fixed_price per the price-breakdown invariant. + * 4. Negative schema cases are rejected and the changed-table identity rule + * holds. + * 5. A table is bound to its quote (account and expiry), and the accepted + * terms price a change the agreement already permits from the table of the + * accepted pricing_option_id, without granting any change right. + * + * SDKs consume the same vector file to check their own resolvers. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const REPO_ROOT = path.join(__dirname, '..'); +const SCHEMA_ROOT = path.join(REPO_ROOT, 'static', 'schemas', 'source'); +const vectors = JSON.parse( + fs.readFileSync( + path.join(REPO_ROOT, 'static', 'compliance', 'source', 'test-vectors', 'price-adjustments', 'vectors.json'), + 'utf8', + ), +); + +function loadSchemaUri(uri) { + if (!uri.startsWith('/schemas/')) throw new Error(`Unexpected schema URI: ${uri}`); + return JSON.parse(fs.readFileSync(path.join(SCHEMA_ROOT, uri.slice('/schemas/'.length)), 'utf8')); +} + +async function compile(uri) { + const ajv = new Ajv({ allErrors: true, strict: false, discriminator: true, loadSchema: loadSchemaUri }); + addFormats(ajv); + return ajv.compileAsync(loadSchemaUri(uri)); +} + +// --- Reference resolver (the normative rule, in exact integer arithmetic) ----- + +const PER_UNIT_MODELS = new Set(['cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp']); + +// Exact decimal parse of a JSON number by value, never by lexical form: +// trailing zeros and exponent notation do not change the result. +function parseDecimal(n) { + const m = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(String(n)); + assert.ok(m, `unparseable number ${n}`); + const frac = (m[3] || '').replace(/0+$/, ''); + const scale = frac.length - Number(m[4] || 0); + const digits = BigInt(m[2] + frac); + return scale >= 0 ? { int: digits, scale } : { int: digits * 10n ** BigInt(-scale), scale: 0 }; +} + +// Fewest decimal places that exactly represent the value. +function decimalPlaces(n) { + const { int, scale } = parseDecimal(n); + let places = scale; + let rest = int; + while (places > 0 && rest % 10n === 0n) { + rest /= 10n; + places -= 1; + } + return places; +} + +function minorExponent(currency) { + const e = vectors.currency_minor_unit_exponent[currency]; + assert.notEqual(e, undefined, `vector currency ${currency} needs an exponent`); + return e; +} + +// Price precision: the greatest of the currency minor-unit exponent, 4 for +// per-unit models, and the decimals that exactly represent fixed_price and each +// row amount. +function pricePrecision(option) { + const places = [minorExponent(option.currency), decimalPlaces(option.fixed_price)]; + if (PER_UNIT_MODELS.has(option.pricing_model)) places.push(4); + for (const row of option.price_adjustments || []) { + if (row.amount !== undefined) places.push(decimalPlaces(row.amount)); + } + return Math.max(...places); +} + +function toScaled(amount, precision) { + const { int, scale } = parseDecimal(amount); + assert.ok(scale <= precision, `${amount} needs more than ${precision} decimals`); + return int * 10n ** BigInt(precision - scale); +} + +function toNumber(scaled, precision) { + return Number(scaled) / 10 ** precision; +} + +function divRoundHalfAwayFromZero(n, d) { + const negative = n < 0n; + const an = negative ? -n : n; + const q = (2n * an + d) / (2n * d); + return negative ? -q : q; +} + +// round half away from zero of running x proportion, where the proportion is +// the exact decimal num/10^scale. +function proportionOf(running, proportion) { + const { int, scale } = parseDecimal(proportion); + return divRoundHalfAwayFromZero(running * int, 10n ** BigInt(scale)); +} + +// Full-identity keys, so different encodings of one value compare equal and the +// same ID under a different scope does not. +function formatKey(ref) { + return ref.scope === 'publisher' ? `p:${ref.publisher_domain}:${ref.format_option_id}` : `l:${ref.format_option_id}`; +} + +// A seller agent is identified by its URL, compared under the AdCP URL +// canonicalization rules (docs/reference/url-canonicalization), not byte equality: +// scheme and host lowercased, default port and userinfo stripped, empty path "/", +// dot segments removed, fragment dropped. +function canonicalAgentUrl(value) { + const url = new URL(value); + url.username = ''; + url.password = ''; + url.hash = ''; + return url.href; +} + +function placementKey(ref) { + return ref.kind === 'seller_inline' + ? `s:${canonicalAgentUrl(ref.seller_agent.agent_url)}:${ref.placement_id}` + : `p:${ref.publisher_domain}:${ref.placement_id}`; +} + +function selectedKeys(selection) { + const placements = selection.placement_selection; + return { + format_option: new Set((selection.format_option_refs || []).map(formatKey)), + placement_selection: new Set( + placements && placements.mode === 'selected' ? placements.placement_refs.map(placementKey) : [], + ), + }; +} + +function rowKey(row) { + return row.dimension === 'format_option' ? formatKey(row.format_option_ref) : placementKey(row.placement_ref); +} + +function rowMatches(row, selected) { + return row.dimension === 'always' || selected[row.dimension].has(rowKey(row)); +} + +function inWindow(row, at) { + if (row.valid_from === undefined && row.valid_until === undefined) return true; + assert.ok(at !== undefined, `row ${row.name} has a window but the case has no flight`); + return ( + (row.valid_from === undefined || at >= Date.parse(row.valid_from)) && + (row.valid_until === undefined || at < Date.parse(row.valid_until)) + ); +} + +// A flight [start, end) that crosses a window boundary of a row that would +// otherwise apply is rejected; the buyer splits it. +function crossesBoundary(option, selection) { + if (!selection.flight) return false; + const start = Date.parse(selection.flight.start_time); + const end = Date.parse(selection.flight.end_time); + const selected = selectedKeys(selection); + return (option.price_adjustments || []).some(row => { + if (!rowMatches(row, selected)) return false; + return [row.valid_from, row.valid_until].some(edge => { + if (edge === undefined) return false; + const t = Date.parse(edge); + return t > start && t < end; + }); + }); +} + +// Declared order; first valid matching row per selection dimension, every valid +// always row. +function firingRows(option, selection) { + const at = selection.flight ? Date.parse(selection.flight.start_time) : undefined; + const selected = selectedKeys(selection); + const fired = new Set(); + const winners = []; + for (const row of option.price_adjustments || []) { + if (!inWindow(row, at) || !rowMatches(row, selected)) continue; + if (row.dimension !== 'always') { + if (fired.has(row.dimension)) continue; + fired.add(row.dimension); + } + winners.push(row); + } + return winners; +} + +function resolve(option, selection) { + if (crossesBoundary(option, selection)) return { error: 'flight_spans_validity_boundary' }; + const precision = pricePrecision(option); + const rows = firingRows(option, selection); + const entries = []; + + // One running total; each firing row applies to it in declared order. A rate + // row adds or subtracts round(running x rate), an amount row its amount. + let running = toScaled(option.fixed_price, precision); + for (const row of rows) { + const delta = row.amount !== undefined ? toScaled(row.amount, precision) : proportionOf(running, row.rate); + running += row.kind === 'fee' ? delta : -delta; + if (delta === 0n) continue; // contributes nothing, omitted from the breakdown + // Recorded as its rate when below 1 (the breakdown schema bound), otherwise as its currency delta. + const entry = { kind: row.kind, name: row.name }; + if (row.rate !== undefined && Number(row.rate) < 1) entry.rate = row.rate; + else entry.amount = toNumber(delta, precision); + entries.push(entry); + } + if (running < 0n) return { error: 'below_zero' }; + return { rows, entries, precision, fixed_price: toNumber(running, precision) }; +} + +// The accepted snapshot records each fired row as its own price_breakdown entry +// after the declared adjustments. +function resolvedSnapshot(option, result) { + const { price_adjustments: _table, ...rest } = option; + const snapshot = { ...rest, fixed_price: result.fixed_price }; + if (!result.entries.length) return snapshot; + const declared = option.price_breakdown; + snapshot.price_breakdown = { + list_price: declared ? declared.list_price : option.fixed_price, + adjustments: [...(declared ? declared.adjustments : []), ...result.entries], + }; + return snapshot; +} + +// The price-breakdown invariant: fold list_price through every adjustment. +function foldBreakdown(breakdown, precision) { + let running = toScaled(breakdown.list_price, precision); + for (const adjustment of breakdown.adjustments) { + const sign = adjustment.kind === 'fee' ? 1n : -1n; + running += + sign * + (adjustment.amount !== undefined ? toScaled(adjustment.amount, precision) : proportionOf(running, adjustment.rate)); + } + return running; +} + +// --- Tests ------------------------------------------------------------------- + +test('every declared option validates as a product pricing option; the closed canonical snapshot carries no table', async () => { + const [validateOffer, validateCanonical] = await Promise.all([ + compile('/schemas/core/pricing-option.json'), + compile('/schemas/core/canonical-pricing-option.json'), + ]); + for (const [name, option] of Object.entries(vectors.options)) { + assert.ok(validateOffer(option), `${name} as offer: ${JSON.stringify(validateOffer.errors)}`); + assert.equal(validateCanonical(option), false, `${name}: canonical snapshot must not embed the table`); + } +}); + +test('resolution cases reproduce the resolved price and ordered application', async () => { + const validateCanonical = await compile('/schemas/core/canonical-pricing-option.json'); + for (const c of vectors.resolution_cases) { + const option = vectors.options[c.option]; + const result = resolve(option, c.selection); + assert.equal(result.error, undefined, `${c.id}: ${result.error}`); + assert.equal(result.fixed_price, c.expect.fixed_price, `${c.id}: fixed_price`); + assert.deepEqual( + result.rows.map(row => row.name), + c.expect.applied, + `${c.id}: applied rows`, + ); + assert.deepEqual(result.entries, c.expect.breakdown, `${c.id}: price_breakdown rows`); + + const snapshot = resolvedSnapshot(option, result); + assert.ok(validateCanonical(snapshot), `${c.id}: snapshot ${JSON.stringify(validateCanonical.errors)}`); + if (snapshot.price_breakdown) { + const folded = foldBreakdown(snapshot.price_breakdown, result.precision); + assert.equal(toNumber(folded, result.precision), snapshot.fixed_price, `${c.id}: breakdown invariant`); + assert.equal( + snapshot.price_breakdown.list_price, + option.price_breakdown ? option.price_breakdown.list_price : option.fixed_price, + `${c.id}: list_price is the base the verifier re-resolves from`, + ); + } else { + assert.equal(snapshot.fixed_price, option.fixed_price, `${c.id}: no row fired, base rate`); + } + } +}); + +test('precision is defined by numeric value, not lexical form', () => { + assert.equal(decimalPlaces(JSON.parse('0.0100')), 2); + assert.equal(decimalPlaces(JSON.parse('20.00')), 0); + assert.equal(decimalPlaces(1e-7), 7); + assert.equal(decimalPlaces(1.5e-7), 8); + assert.equal(decimalPlaces(0.00125), 5); + assert.equal(decimalPlaces(1000), 0); +}); + +test('purchases with no valid resolution are rejected, never clamped', () => { + for (const c of vectors.invalid_resolution_cases) { + assert.equal(resolve(vectors.options[c.option], c.selection).error, c.expect_error, c.id); + assert.equal(c.expect_outcome, 'INVALID_REQUEST'); + } +}); + +test('buyer-supplied pricing must equal the resolved result', () => { + for (const c of vectors.buyer_supplied_pricing_cases) { + const result = resolve(vectors.options[c.option], c.selection); + const outcome = result.fixed_price === c.supplied_fixed_price ? 'accepted' : 'INVALID_REQUEST'; + assert.equal(outcome, c.expect, c.id); + if (c.error_field) { + assert.match(c.error_field, /^pricing\./); + assert.equal(result.fixed_price, c.resolved_fixed_price, `${c.id}: resolved value carried in error details`); + assert.deepEqual(result.entries, c.resolved_breakdown, `${c.id}: resolved breakdown carried in error details`); + } + } +}); + +test('a changed table requires a distinct pricing_option_id', () => { + for (const c of vectors.identity_cases) { + const previous = vectors.options[c.previous]; + const current = vectors.options[c.current]; + assert.notDeepEqual(previous.price_adjustments, current.price_adjustments, c.id); + assert.notEqual(previous.pricing_option_id, current.pricing_option_id, c.id); + } +}); + +test('schema negative cases are rejected', async () => { + const validateOffer = await compile('/schemas/core/pricing-option.json'); + for (const c of vectors.schema_negative_cases) { + const option = { ...vectors.options.cpm_rate_card }; + for (const [key, value] of Object.entries(c.mutate)) { + if (value === null) delete option[key]; + else option[key] = value; + } + assert.equal(validateOffer(option), false, `${c.id} should be invalid as an offer`); + } +}); + +// A table is bound to its quote: the account it was returned for, until the +// quote (proposal, offer, or discovery response) expires. +function tableApplies(quote, use) { + return use.account_id === quote.account_id && Date.parse(use.at) < Date.parse(quote.expires_at); +} + +test('a table applies only to the quoted account and expires with the quote', () => { + for (const c of vectors.quote_binding_cases) { + assert.equal(tableApplies(c.quote, c.use) ? 'table_applies' : 'table_does_not_apply', c.expect, c.id); + } +}); + +// A changed table is a new pricing_option_id, so the accepted pricing_option_id +// identifies exactly one immutable table. A change the agreement already permits +// is priced from that table, never from a later one, without a re-quote. The +// table itself grants no change right: permission comes only from change_terms[]. +function priceAcceptedChange(option, c) { + if (!c.change_permitted_by_change_terms) return { outcome: 'ACTION_NOT_ALLOWED' }; + return { outcome: 'priced', fixed_price: resolve(option, c.requested_selection).fixed_price }; +} + +test('accepted terms price a permitted change from the accepted option\'s table and grant no change right', () => { + for (const c of vectors.accepted_terms_cases) { + assert.deepEqual(priceAcceptedChange(vectors.options[c.option], c), c.expect, c.id); + if (c.later_option) { + const later = vectors.options[c.later_option]; + assert.notEqual(later.pricing_option_id, vectors.options[c.option].pricing_option_id, c.id); + assert.notEqual(resolve(later, c.requested_selection).fixed_price, c.expect.fixed_price, `${c.id}: later table prices differently`); + } + } +}); + +test('order of rows is the order of application', () => { + const [ratesThenAmount, amountThenRates] = [vectors.options.cpm_placement_first, vectors.options.cpm_rate_card]; + const selection = { + format_option_refs: [{ scope: 'product', format_option_id: 'video_30s' }], + placement_selection: { + mode: 'selected', + placement_refs: [{ publisher_domain: 'streamhaus.example', placement_id: 'premium_home' }], + }, + }; + // 20 x 1.15 + 2 = 25.00 versus (20 + 2) x 1.15 = 25.30: the same two rows, declared in the other order. + assert.equal(resolve(ratesThenAmount, selection).fixed_price, 25); + assert.equal(resolve(amountThenRates, selection).fixed_price, 25.3); +});