diff --git a/.changeset/revenue-share-media-pricing.md b/.changeset/revenue-share-media-pricing.md new file mode 100644 index 0000000000..1c0322e03d --- /dev/null +++ b/.changeset/revenue-share-media-pricing.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": minor +--- + +Add contingent revenue-share pricing for affiliate and other outcome-priced media. + +The new `revenue_share` pricing option applies a decimal `commission_rate` to settled `commissionable_value`. Product discovery can filter fixed, auction, and contingent pricing independently; delivery and `report_usage` expose the commission basis for formula-checked reconciliation. + +Revenue-share packages bind billing to an event source and measurement window, do not use `bid_price`, and treat package budget as the maximum payable commission. diff --git a/docs/accounts/tasks/report_usage.mdx b/docs/accounts/tasks/report_usage.mdx index ca33297a3d..b5960bdc10 100644 --- a/docs/accounts/tasks/report_usage.mdx +++ b/docs/accounts/tasks/report_usage.mdx @@ -5,13 +5,13 @@ description: "report_usage sends consumption data to AdCP vendor agents after ca testable: false --- -Reports how a vendor's service was consumed after campaign delivery. Called by orchestrators to inform a vendor agent (signals, governance, creative) what was used so the vendor can track earned revenue and verify billing. +Reports how a vendor's service was consumed after campaign delivery. Called by orchestrators to inform a vendor agent (signals, governance, creative) what was used so the vendor can track earned revenue and verify billing. It is also used against the sales agent itself when a media buy names the buyer or a measurement vendor as the billing authority. Each usage record is self-contained — it carries its own `account` and `media_buy_id`. A single request can span multiple accounts and campaigns. **Response Time**: ~1s. -**Request Schema**: [`/schemas/v3/account/report-usage-request.json`](https://adcontextprotocol.org/schemas/v3/account/report-usage-request.json) +{/* Add the /schemas/v3/ request link once the revenue-share fields ship in a stable release. */} **Response Schema**: [`/schemas/v3/account/report-usage-response.json`](https://adcontextprotocol.org/schemas/v3/account/report-usage-response.json) ## Request Parameters @@ -34,11 +34,17 @@ Each record requires `account`, `vendor_cost`, and `currency`. Additional fields | `pricing_option_id` | string | Vendor: Yes | Pricing option from the vendor's discovery response (`get_signals`, `list_creatives`, `list_content_standards`, `list_property_lists`) or execution response (`build_creative`). The vendor uses this to verify the correct rate was applied. | | `impressions` | number | Signals: Yes | Impressions delivered | | `media_spend` | number | percent_of_media: Yes | Media spend for percent-of-media cost verification | +| `media_buy_id` | string | Revenue share: Yes | Media buy whose selected package pricing is being reconciled. | +| `conversions` | number | No | Number of attributed conversion events. Optional analytics context for revenue share. | +| `conversion_value` | number | No | Total attributed conversion value. Optional analytics context; not the revenue-share billing basis. | +| `commissionable_value` | number | Revenue share: Yes | Settled attributed value eligible for commission. The seller verifies `vendor_cost = round_currency(commissionable_value × commission_rate)`. | | `signal_agent_segment_id` | string | Signals: Yes | Signal identifier from `get_signals` | | `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. A `build_creative` variant leaf earns a `creative_id` only when trafficked/added to the library — discarded best-of-N or fan-out variants are never reported here; their charge is the inline per-leaf `vendor_cost` on the `build_creative` response (the authoritative record for untrafficked leaves). | | `build_variant_id` | string | No | When the reported `creative_id` was promoted from a specific `build_creative` variant leaf but differs from that source id, carry the source `build_variant_id` for reconciliation. On the canonical path where `creative_id` is the kept `build_variant_id`, omit this field. | | `property_list_id` | string | Property lists: Yes | Property list identifier from `list_property_lists`. Links usage to a specific property list for billing verification. | +For revenue-share records, `pricing_option_id` identifies the agreed rate and commission basis. `currency` must match the selected option, and `vendor_cost` must not exceed the package's commission budget. + ## Response | Field | Description | diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx index 9065676ee5..8d450d3d3f 100644 --- a/docs/media-buy/advanced-topics/pricing-models.mdx +++ b/docs/media-buy/advanced-topics/pricing-models.mdx @@ -1,6 +1,6 @@ --- title: Pricing Models -description: "AdCP pricing models — CPM, CPCV, CPP, CPC, CPA, time-based, and DOOH pricing. Publisher-declared, buyer-selected model with rate card support." +description: "AdCP pricing models — CPM, CPCV, CPP, CPC, CPA, revenue share, time-based, and DOOH pricing. Publisher-declared, buyer-selected model with rate card support." "og:title": "AdCP — Pricing Models" --- @@ -384,6 +384,50 @@ Buyers should verify the measurement provider meets their campaign requirements --- +### Revenue Share +**Percentage of settled attributed value** - Advertiser pays a fixed commission rate on the portion of attributed conversion value eligible under the parties' agreement. + +**Use Cases**: Affiliate networks, content commerce, performance partnerships, and other outcome-priced media + +{/* Add $schema once revenue-share-option.json ships through the /schemas/v3/ alias. */} +```json +{ + "pricing_option_id": "affiliate_purchase_4pct", + "pricing_model": "revenue_share", + "event_type": "purchase", + "event_source_id": "affiliate_attribution", + "commission_rate": 0.04, + "currency": "USD", + "commission_basis_description": "Net merchandise value after discounts, excluding tax and shipping; returns removed before commission lock." +} +``` + +`commission_rate` is a decimal proportion: `0.04` means 4%. Revenue share uses contingent pricing, not fixed-unit or auction pricing, so the option does not carry `fixed_price`, `floor_price`, `price_guidance`, or `max_bid`, and the package request does not carry `bid_price`. + +**Billing formula**: + +```text +spend = round_currency(commissionable_value * commission_rate) +``` + +`commissionable_value` is the settled value eligible for commission. It is distinct from `conversion_value`, which remains the total attributed value for analytics. The difference may reflect excluded tax or shipping, discounts, ineligible items, returns, or cancellations. `commission_basis_description` tells operators how the authoritative party calculates the eligible amount. + +**Settlement flow**: + +1. The buyer registers the declared `event_source_id` through `sync_event_sources`. +2. The package's `measurement_terms.billing_measurement` names the authoritative party and commission-lock `measurement_window`. +3. Provisional delivery may report conversion and commissionable values while the return window is open. +4. `adjusted` or `window_update` reports replace earlier values as returns and cancellations settle. +5. A final row reports `commissionable_value`, calculated `spend`, `is_final: true`, and `finalized_at`. + +For revenue share, package `budget` is the maximum commission payable—not attributed sales. Sellers must not invoice above that budget without an accepted budget update. + +When billing is buyer- or vendor-attested, `report_usage` carries `media_buy_id`, `pricing_option_id`, `commissionable_value`, and `vendor_cost`; the seller verifies the formula against the selected pricing option. + +Revenue share is channel-independent. Affiliate is the immediate use case, but sellers may offer this pricing model on other channels where the same commercial contract applies. + +--- + ### Flat Rate **Fixed cost** - Single payment regardless of delivery volume. diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index b3e660cf73..dc2e8e2624 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -282,6 +282,7 @@ Buyers receive the intersection of both. `impressions` and `spend` are always re - **`completion_rate`**: Completion rate (`completed_views` / `impressions`); `null` when not applicable (e.g. a non-video buy) - **`conversions`**: Post-click or post-view conversions - **`conversion_value`**: Monetary value of attributed conversions +- **`commissionable_value`**: Settled portion of attributed conversion value eligible for revenue-share commission. Distinct from `conversion_value` when the agreement excludes taxes, shipping, discounts, returns, cancellations, or ineligible items. - **`roas`**: Return on ad spend - **`cost_per_acquisition`**: Cost per conversion - **`new_to_brand_rate`**: Fraction of conversions from first-time brand buyers diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 0c741a0e03..9e17eed0dc 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -107,6 +107,7 @@ Publishers declare which pricing models they support for each product. Buyers se - **CPCV** (Cost Per Completed View) - Cost per 100% video/audio completion - **CPV** (Cost Per View) - Cost per view at publisher-defined threshold - **CPA** (Cost Per Acquisition) - Cost per conversion event (purchase, lead, signup, etc.) +- **Revenue Share** - Commission rate applied to settled attributed conversion value - **CPP** (Cost Per Point) - Cost per Gross Rating Point (TV/audio) - **Flat Rate** - Fixed cost regardless of delivery volume - **Time** - Cost per time unit (day, week, month) that scales with campaign duration @@ -125,7 +126,7 @@ Each pricing option includes: } ``` -For auction-based pricing (no `fixed_price`), use `floor_price` for minimum bid constraints and optional `price_guidance` for percentile hints. Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include `max_bid` as a boolean signal that `bid_price` switches from exact honored price to buyer ceiling mode: +For auction-based unit pricing (no `fixed_price`), use `floor_price` for minimum bid constraints and optional `price_guidance` for percentile hints. Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include `max_bid` as a boolean signal that `bid_price` switches from exact honored price to buyer ceiling mode. `revenue_share` is the exception to the old "no fixed price means auction" shortcut: it is contingent pricing, uses `commission_rate`, and never uses `bid_price`. ```json { "$schema": "https://adcontextprotocol.org/schemas/v3/pricing-options/cpm-option.json", diff --git a/docs/media-buy/task-reference/get_media_buy_delivery.mdx b/docs/media-buy/task-reference/get_media_buy_delivery.mdx index 2abe59bbe7..373db6945b 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -671,7 +671,8 @@ The `by_package` array provides per-package delivery details with these key fiel **Performance**: - **`pacing_index`**: Delivery pace (1.0 = on track, below 1.0 = behind, above 1.0 = ahead) - **`rate`**: Effective pricing rate (e.g., CPM) -- **`pricing_model`**: How the package is billed (cpm, cpcv, cpp, etc.) +- **`pricing_model`**: How the package is billed (cpm, cpcv, cpp, revenue_share, etc.) +- **`commissionable_value`**: For `revenue_share`, the settled attributed value to which the selected `commission_rate` applies. Verify `spend = round_currency(commissionable_value × commission_rate)`. **Accountability**: - **`missing_metrics`**: Metrics the binding reporting contract advertised but that are not populated in this report. Each entry uses an explicit `scope` discriminator: `{ "scope": "standard", "metric_id": "completed_views" }` for entries from the closed `available-metric.json` enum, `{ "scope": "vendor", "vendor": { "domain": "..." }, "metric_id": "attention_units" }` for vendor-defined metrics. Standard entries MAY carry a `qualifier` mirroring the `committed_metrics` qualifier (e.g., `{ "scope": "standard", "metric_id": "viewable_rate", "qualifier": { "viewability_standard": "mrc" } }` flags a missing MRC commitment even when GroupM viewability was reported, or `{ "scope": "standard", "metric_id": "completion_rate", "qualifier": { "completion_source": "vendor_attested" } }` flags a missing vendor-attested commitment even when seller-attested completion was reported — the paths are not interchangeable). Reconciled against `package.committed_metrics` (filtered to entries where `committed_at < reporting_period.end`) when present; falls back to the product's current `reporting_capabilities.available_metrics` and `vendor_metrics` when absent. Empty array (or absent) indicates clean delivery against the contract; non-empty signals an accountability breach. Sellers MUST exclude metrics that are not yet measurable for the current `measurement_window` (e.g., post-IVT counts during the live window) — those will appear (or not) when a wider window supersedes this report via `supersedes_window`. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 452289dcbf..1c0e0f7eec 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -164,7 +164,8 @@ The seller must declare `features.property_list_filtering: true` in [`get_adcp_c | Parameter | Type | Description | |-----------|------|-------------| | `delivery_type` | string | Filter by `"guaranteed"` or `"non_guaranteed"` | -| `is_fixed_price` | boolean | Filter for fixed-price vs auction products. Products with both pricing types can match either value, but the returned `pricing_options` array must include only options matching the requested pricing type so buyers can select deterministically from discovery. | +| `is_fixed_price` | boolean | Legacy filter for fixed-price vs auction products. `true` returns options with `fixed_price`; `false` returns options established through `bid_price`. Contingent options such as `revenue_share` match neither value and are omitted whenever this filter is present. | +| `pricing_structures` | string[] | Filter by how the payable price is determined: `fixed`, `auction`, or `contingent`. Sellers return only matching `pricing_options`. Use `contingent` to discover `revenue_share`. When combined with `is_fixed_price`, both filters apply. | | `pricing_currencies` | string[] | Filter by ISO 4217 currencies the buyer can use for the media product transaction (e.g., `["USD"]`). Products match when they offer at least one product-level `pricing_options` entry in one of the requested currencies and any seller-applied or otherwise mandatory product-scoped signal charges are satisfiable in one of those currencies or have no incremental price. Sellers MUST return only matching product `pricing_options` so buyers can select deterministically from discovery. Optional signal or vendor add-on pricing is not pruned by this filter. | | `format_ids` | FormatID[] | Filter by specific format IDs | | `standard_formats_only` | boolean | Only return products accepting IAB standard formats | @@ -245,7 +246,7 @@ Use `filters.pricing_currencies` when the buyer's constraint is "only show produ Buyers MAY send both. Sellers apply them conjunctively: `budget_range.currency` denominates the budget amounts, while `pricing_currencies` narrows which returned product `pricing_options` are eligible. If the two fields conflict, sellers SHOULD return zero matching products rather than reject the request solely because of the conflict. Because product-scoped signal pricing is a separate add-on surface, this filter only gates mandatory seller-applied signal charges; optional signal or vendor add-ons may still advertise other currencies, and buyers should not select unsupported add-on prices. -When combined with `is_fixed_price`, returned product `pricing_options` MUST satisfy both filters: the option must be fixed-price when requested and its `currency` must be in `pricing_currencies`. +When combined with `is_fixed_price` or `pricing_structures`, returned product `pricing_options` MUST satisfy every filter: each retained option must have a requested pricing structure and its `currency` must be in `pricing_currencies`. Currency-only filter example: diff --git a/docs/protocol/capabilities-explorer.mdx b/docs/protocol/capabilities-explorer.mdx index ea050a57f9..32bf5b17f3 100644 --- a/docs/protocol/capabilities-explorer.mdx +++ b/docs/protocol/capabilities-explorer.mdx @@ -75,7 +75,7 @@ The largest domain. Sub-namespaces are where most media-buying flags belong. - **`offline_delivery_protocols`** — supported offline delivery protocols (broadcast trafficking, etc.). - **`portfolio`** — portfolio-management capabilities. - **`reporting_delivery_methods`** — how reporting is delivered. -- **`supported_pricing_models`** — array (CPM, CPC, CPCV, CPP, fixed, etc.). +- **`supported_pricing_models`** — array (CPM, CPC, CPCV, CPP, revenue share, flat rate, etc.). [Propose a flag for `media_buy.features`](https://github.com/adcontextprotocol/adcp/issues/new?title=Add+media_buy+feature:+%3Cflag%3E&body=Schema+location:+%60.media_buy.features%60%0A%0A%23%23+Proposal%0A%0A...%0A%0A%23%23+Why+a+feature+flag+and+not+a+new+task%0A%0A...%0A%0A%23%23+Conformance+probe%0A%0AHow+can+a+buyer+verify+this+capability+is+actually+honored?+...&labels=rfc,capabilities) diff --git a/docs/reference/media-channel-taxonomy.mdx b/docs/reference/media-channel-taxonomy.mdx index 0075e7e0ab..46492544e1 100644 --- a/docs/reference/media-channel-taxonomy.mdx +++ b/docs/reference/media-channel-taxonomy.mdx @@ -472,7 +472,7 @@ Affiliate networks, comparison sites, and performance-based publisher partnershi **Typical Formats**: native, display, text -**Note**: `affiliate` describes the BUYING MODEL (performance-based, CPA/CPC/rev-share) rather than where content appears. +**Note**: `affiliate` describes the BUYING MODEL (performance-based, CPA/CPC/revenue share) rather than where content appears. Percentage-of-sale terms use the [`revenue_share` pricing model](/docs/media-buy/advanced-topics/pricing-models#revenue-share). #### `product_placement` diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index bad7037e6d..963686cba9 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -935,6 +935,7 @@ const PRICING_ALIASES: Record = { 'flat': 'flat_rate', 'flat rate': 'flat_rate', 'sponsorship': 'flat_rate', 'cost per view': 'cpv', 'cost per action': 'cpa', 'cost per acquisition': 'cpa', + 'revenue share': 'revenue_share', 'rev share': 'revenue_share', 'commission': 'revenue_share', }; function normalizeChannel(ch: string): string { @@ -6310,7 +6311,7 @@ export function createMemberToolHandlers( product_id: bestProduct.product_id, pricing_option_id: bestPricing.pricing_option_id, budget: liBudget || 0, - ...(bestPricing.pricing_model !== 'flat_rate' && liRate ? { bid_price: liRate } : {}), + ...(!['flat_rate', 'revenue_share'].includes(bestPricing.pricing_model) && liRate ? { bid_price: liRate } : {}), ...(liStartDate ? { start_time: liStartDate } : {}), ...(liEndDate ? { end_time: liEndDate } : {}), } : undefined; diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index 033d5ff328..a2d29ec5d8 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -122,6 +122,8 @@ function applyExtendedDeliveryParams(cumulative: ComplyDeliveryAccumulator, para if (typeof params.measurement_window === 'string') cumulative.measurementWindow = params.measurement_window; if (typeof params.reach === 'number') cumulative.reach = params.reach; if (typeof params.frequency === 'number') cumulative.frequency = params.frequency; + if (typeof params.conversion_value === 'number') cumulative.conversionValue = params.conversion_value; + if (typeof params.commissionable_value === 'number') cumulative.commissionableValue = params.commissionable_value; if (params.reach_window && typeof params.reach_window === 'object' && !Array.isArray(params.reach_window)) { cumulative.reachWindow = params.reach_window as ComplyDeliveryAccumulator['reachWindow']; } @@ -137,6 +139,8 @@ function extendedDeliverySnapshot(cumulative: ComplyDeliveryAccumulator): Record ...(cumulative.measurementWindow ? { measurement_window: cumulative.measurementWindow } : {}), ...(cumulative.reach !== undefined ? { reach: cumulative.reach } : {}), ...(cumulative.frequency !== undefined ? { frequency: cumulative.frequency } : {}), + ...(cumulative.conversionValue !== undefined ? { conversion_value: cumulative.conversionValue } : {}), + ...(cumulative.commissionableValue !== undefined ? { commissionable_value: cumulative.commissionableValue } : {}), ...(cumulative.reachWindow ? { reach_window: cumulative.reachWindow } : {}), ...(cumulative.viewability ? { viewability: cumulative.viewability } : {}), }; @@ -545,6 +549,8 @@ function createStore(session: SessionState, sessionKey: string, principal?: stri if (clicks) simulated.clicks = clicks; if (reportedSpend) simulated.reported_spend = reportedSpend; if (conversions) simulated.conversions = conversions; + if (typedParams.conversion_value !== undefined) simulated.conversion_value = typedParams.conversion_value; + if (typedParams.commissionable_value !== undefined) simulated.commissionable_value = typedParams.commissionable_value; if (typedParams.reach !== undefined) simulated.reach = typedParams.reach; if (typedParams.frequency !== undefined) simulated.frequency = typedParams.frequency; if (typedParams.reach_window !== undefined) simulated.reach_window = typedParams.reach_window; @@ -1081,6 +1087,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo const simulatedExtras: Record = {}; if (params.reach !== undefined) simulatedExtras.reach = params.reach; if (params.frequency !== undefined) simulatedExtras.frequency = params.frequency; + if (params.conversion_value !== undefined) simulatedExtras.conversion_value = params.conversion_value; + if (params.commissionable_value !== undefined) simulatedExtras.commissionable_value = params.commissionable_value; if (params.reach_window !== undefined) simulatedExtras.reach_window = params.reach_window; if (params.viewability !== undefined) simulatedExtras.viewability = params.viewability; if (params.is_final !== undefined) simulatedExtras.is_final = params.is_final; diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 40209ed395..37222f8d69 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -89,7 +89,18 @@ type PackageUpdateExt = PackageUpdate & { type Destination = NonNullable[number]; type SignalFilters = NonNullable; type PricingOption = Product['pricing_options'][number]; -type AuctionPricingOption = Exclude; +type PricingStructure = 'fixed' | 'auction' | 'contingent'; +type PricingOptionView = { + pricing_option_id?: string; + pricing_model?: string; + currency?: string; + fixed_price?: number; + floor_price?: number; + price_guidance?: { p50?: number }; + commission_rate?: number; + event_source_id?: string; + min_spend_per_package?: number; +}; type WholesaleFeedRequest = { account?: AccountRef; if_wholesale_feed_version?: string; @@ -524,22 +535,39 @@ function productMeasurementCatalogForGoal(product: Product | undefined, goal: Ve }); } -function hasFixedPrice(option: PricingOption): boolean { - return (option as { fixed_price?: unknown }).fixed_price !== undefined; +export function pricingStructureForOption(option: unknown): PricingStructure { + if (!option || typeof option !== 'object' || Array.isArray(option)) return 'auction'; + const view = option as PricingOptionView; + if (view.pricing_model === 'revenue_share') return 'contingent'; + return view.fixed_price !== undefined ? 'fixed' : 'auction'; } function applyFixedPriceFilter(product: Product, fixedPrice: boolean): Product | null { - const pricing_options = product.pricing_options.filter(po => hasFixedPrice(po) === fixedPrice); + const requested: PricingStructure = fixedPrice ? 'fixed' : 'auction'; + const pricing_options = product.pricing_options.filter(po => pricingStructureForOption(po) === requested); if (pricing_options.length === 0) return null; return { ...product, pricing_options }; } -function applyFixedPriceFilterToProducts(products: Product[], fixedPrice: boolean): Product[] { +export function applyFixedPriceFilterToProducts(products: Product[], fixedPrice: boolean): Product[] { return products .map(product => applyFixedPriceFilter(product, fixedPrice)) .filter((product): product is Product => product !== null); } +function applyPricingStructuresFilter(product: Product, structures: Set): Product | null { + const pricing_options = product.pricing_options.filter(option => structures.has(pricingStructureForOption(option))); + if (pricing_options.length === 0) return null; + return { ...product, pricing_options }; +} + +export function applyPricingStructuresFilterToProducts(products: Product[], structures: PricingStructure[]): Product[] { + const requested = new Set(structures); + return products + .map(product => applyPricingStructuresFilter(product, requested)) + .filter((product): product is Product => product !== null); +} + function pricingOptionCurrency(option: unknown): string | undefined { if (!option || typeof option !== 'object' || Array.isArray(option)) return undefined; const currency = (option as { currency?: unknown }).currency; @@ -3131,6 +3159,10 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P if (typeof fixedPriceFilter === 'boolean') { products = applyFixedPriceFilterToProducts(products, fixedPriceFilter); } + const pricingStructures = (req.filters as { pricing_structures?: PricingStructure[] }).pricing_structures; + if (pricingStructures?.length) { + products = applyPricingStructuresFilterToProducts(products, pricingStructures); + } const requiredVendorMetrics = (req.filters as { required_vendor_metrics?: Array<{ vendor?: { domain?: string }; metric_id?: string }> }).required_vendor_metrics; if (requiredVendorMetrics?.length) { products = products.filter(p => { @@ -4890,12 +4922,9 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) // Auction pricing needs a bid_price — use price_guidance p50 or floor_price let bidPrice: number | undefined; - if (pricing && pricing.pricing_model !== 'cpa') { - const po = pricing as AuctionPricingOption; - const hasFixed = po.fixed_price !== undefined; - if (!hasFixed) { - bidPrice = po.price_guidance?.p50 ?? po.floor_price; - } + if (pricing && pricingStructureForOption(pricing) === 'auction') { + const po = pricing as unknown as PricingOptionView; + bidPrice = po.price_guidance?.p50 ?? po.floor_price; } return { @@ -5012,10 +5041,10 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) } } - // Check bid vs floor price (floor_price exists on all pricing models except CPA) - const floorPrice = pricing.pricing_model !== 'cpa' ? pricing.floor_price : undefined; - const isAuction = pricing.pricing_model !== 'cpa' - && !('fixed_price' in pricing && (pricing as AuctionPricingOption).fixed_price !== undefined); + const pricingView = pricing as unknown as PricingOptionView; + const pricingStructure = pricingStructureForOption(pricing); + const floorPrice = pricingStructure === 'auction' ? pricingView.floor_price : undefined; + const isAuction = pricingStructure === 'auction'; const seededPricingKey = `${pkg.product_id}:${pkg.pricing_option_id}`; const allowSeededMetricFloorCoercion = Boolean( floorPrice !== undefined @@ -5034,6 +5063,26 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) } as TaskError); } + if (pricingStructure === 'contingent' && pkg.bid_price !== undefined) { + errors.push({ + code: 'INVALID_REQUEST', + message: `${pkgLabel}: bid_price is not valid for contingent pricing (pricing option ${pkg.pricing_option_id})`, + field: `packages[${i}].bid_price`, + } as TaskError); + } + + if ( + pricingView.pricing_model === 'revenue_share' + && pricingView.event_source_id + && !findEventSourceInSession(sessionKeyForEventSources, pricingView.event_source_id) + ) { + errors.push({ + code: 'INVALID_REQUEST', + message: `event_source_id "${pricingView.event_source_id}" from revenue-share pricing option "${pkg.pricing_option_id}" was not registered via sync_event_sources`, + field: `packages[${i}].pricing_option_id`, + } as TaskError); + } + if (floorPrice !== undefined && pkg.bid_price !== undefined && pkg.bid_price < floorPrice && !allowSeededMetricFloorCoercion) { errors.push({ code: 'INVALID_REQUEST', @@ -5042,7 +5091,7 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) } // Check min spend - const minSpend = pricing.min_spend_per_package; + const minSpend = pricingView.min_spend_per_package; if (minSpend && pkg.budget < minSpend) { errors.push({ code: 'INVALID_REQUEST', @@ -5376,7 +5425,9 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon const req = args as unknown as GetMediaBuyDeliveryRequest & ToolArgs & { media_buy_id?: string }; const session = await getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); const catalog = getCatalog(); - const productMap = new Map(catalog.map(cp => [cp.product.product_id, cp.product])); + const productMap = new Map(catalog.map(cp => [cp.product.product_id, { ...cp.product }])); + overlaySeededProducts(session, productMap); + overlayNegotiatedPricingOptions(session, productMap); const mediaBuyId = req.media_buy_id || req.media_buy_ids?.[0] || ''; const mb = session.mediaBuys.get(mediaBuyId) ?? getComplianceMediaBuy(mediaBuyId); @@ -5429,10 +5480,12 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon }; } - const budget = pkg.budget; - const spend = Math.round(budget * elapsed * 100) / 100; - const { model: pricingModel, rate } = derivePricing(pkg, productMap); + const isRevenueShare = pricingModel === 'revenue_share'; + const budget = pkg.budget; + const spend = isRevenueShare + ? (simDelivery?.reportedSpend.amount ?? 0) + : Math.round(budget * elapsed * 100) / 100; // Channel-appropriate CTR const product = productMap.get(pkg.productId); @@ -5446,12 +5499,16 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon else if (channels?.some(c => ['print'].includes(c))) ctr = 0; else ctr = 0.001; - const impressions = rate > 0 ? Math.round((spend / rate) * 1000) : 0; - const clicks = Math.round(impressions * ctr); + const impressions = isRevenueShare + ? (simDelivery?.impressions ?? 0) + : rate > 0 ? Math.round((spend / rate) * 1000) : 0; + const clicks = isRevenueShare ? (simDelivery?.clicks ?? 0) : Math.round(impressions * ctr); - totalImpressions += impressions; - totalSpend += spend; - totalClicks += clicks; + if (!isRevenueShare) { + totalImpressions += impressions; + totalSpend += spend; + totalClicks += clicks; + } // Audio/video metrics — completion rates vary by channel // Accumulators for totals rollup are updated after audioMetrics is built @@ -5514,6 +5571,11 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon clicks, ...audioMetrics, ...byCreative, + ...(isRevenueShare && simDelivery ? { + conversions: simDelivery.conversions, + ...(simDelivery.conversionValue !== undefined ? { conversion_value: simDelivery.conversionValue } : {}), + ...(simDelivery.commissionableValue !== undefined ? { commissionable_value: simDelivery.commissionableValue } : {}), + } : {}), pricing_model: pricingModel, model: pricingModel, // #1525: alias for @adcp/sdk < 4.11.0 rate, @@ -5553,6 +5615,12 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon : totalConversions > 0 ? { conversions: totalConversions } : {}; + const conversionValueTotals = simDelivery + ? { + ...(simDelivery.conversionValue !== undefined ? { conversion_value: simDelivery.conversionValue } : {}), + ...(simDelivery.commissionableValue !== undefined ? { commissionable_value: simDelivery.commissionableValue } : {}), + } + : {}; // Click-attributed total. cost_per_click is defined as spend / clicks in // delivery-metrics.json. Always surface when both are positive — this @@ -5664,6 +5732,7 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon ...goalDerivedReach, ...simulatedReachMetrics, ...conversionTotals, + ...conversionValueTotals, ...simulatedViewability, }, by_package: byPackage, @@ -5674,10 +5743,12 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon function derivePricing(pkg: PackageState, productMap: Map): { model: string; rate: number } { const product = productMap.get(pkg.productId); const pricing = product?.pricing_options.find(po => po.pricing_option_id === pkg.pricingOptionId); + const view = pricing as unknown as PricingOptionView | undefined; return { - model: pricing?.pricing_model || 'cpm', - rate: pricing?.fixed_price - ?? (pricing && pricing.pricing_model !== 'cpa' ? pricing.floor_price : undefined) + model: view?.pricing_model || 'cpm', + rate: view?.commission_rate + ?? view?.fixed_price + ?? (pricingStructureForOption(view) === 'auction' ? view?.floor_price : undefined) ?? 10, }; } @@ -7568,11 +7639,19 @@ interface ReportUsageArgs extends ToolArgs { reporting_period: { start: string; end: string }; usage: Array<{ account: { account_id?: string; brand?: { domain: string }; operator?: string }; + media_buy_id?: string; creative_id?: string; signal_agent_segment_id?: string; + standards_id?: string; + rights_id?: string; + build_variant_id?: string; + property_list_id?: string; pricing_option_id?: string; impressions?: number; media_spend?: number; + conversions?: number; + conversion_value?: number; + commissionable_value?: number; vendor_cost: number; currency: string; final?: boolean; @@ -7581,6 +7660,60 @@ interface ReportUsageArgs extends ToolArgs { }>; } +function roundCurrency(value: number, currency: string): number { + let fractionDigits = 2; + try { + fractionDigits = new Intl.NumberFormat('en', { style: 'currency', currency }) + .resolvedOptions().maximumFractionDigits ?? 2; + } catch { + // The schema constrains shape but not membership in the ISO registry. The + // handler's existing behavior accepts unknown three-letter codes, so keep + // the conventional two-decimal fallback rather than adding a new rejection. + } + const factor = 10 ** fractionDigits; + return Math.round((value + Number.EPSILON) * factor) / factor; +} + +function effectiveProductMapForSession(session: SessionState): Map { + const productMap = new Map(getCatalog().map(cp => [cp.product.product_id, { ...cp.product }])); + overlaySeededProducts(session, productMap); + overlayNegotiatedPricingOptions(session, productMap); + return productMap; +} + +type RevenueShareUsageContext = { + rate: number; + currency: string; + budget: number; + pricingOptionId: string; +}; + +function pricingContextsForUsage( + session: SessionState, + record: ReportUsageArgs['usage'][number], +): { packagePricingOptionIds: Set; revenueShares: RevenueShareUsageContext[] } | undefined { + if (!record.media_buy_id) return undefined; + const mediaBuy = session.mediaBuys.get(record.media_buy_id); + if (!mediaBuy) return undefined; + const products = effectiveProductMapForSession(session); + const revenueShares: RevenueShareUsageContext[] = []; + for (const pkg of mediaBuy.packages) { + const product = products.get(pkg.productId); + const option = product?.pricing_options.find(candidate => candidate.pricing_option_id === pkg.pricingOptionId) as unknown as PricingOptionView | undefined; + if (option?.pricing_model !== 'revenue_share' || option.commission_rate === undefined || !option.currency) continue; + revenueShares.push({ + rate: option.commission_rate, + currency: option.currency, + budget: pkg.budget, + pricingOptionId: pkg.pricingOptionId, + }); + } + return { + packagePricingOptionIds: new Set(mediaBuy.packages.map(pkg => pkg.pricingOptionId)), + revenueShares, + }; +} + // ── get_creative_features (truth-of-claim verifier; closes #3802) ── // // Governance-agent-shaped handler the training agent exposes so the @@ -7868,6 +8001,86 @@ export async function handleReportUsage(args: ToolArgs, ctx: TrainingContext) { errors.push({ code: 'INVALID_USAGE_DATA', message: 'impressions must be non-negative.', field: `usage[${i}].impressions` }); continue; } + if (record.conversions !== undefined && record.conversions < 0) { + errors.push({ code: 'INVALID_USAGE_DATA', message: 'conversions must be non-negative.', field: `usage[${i}].conversions` }); + continue; + } + if (record.conversion_value !== undefined && record.conversion_value < 0) { + errors.push({ code: 'INVALID_USAGE_DATA', message: 'conversion_value must be non-negative.', field: `usage[${i}].conversion_value` }); + continue; + } + if (record.commissionable_value !== undefined && record.commissionable_value < 0) { + errors.push({ code: 'INVALID_USAGE_DATA', message: 'commissionable_value must be non-negative.', field: `usage[${i}].commissionable_value` }); + continue; + } + + const usesVendorPricingNamespace = Boolean( + record.creative_id + || record.signal_agent_segment_id + || record.standards_id + || record.rights_id + || record.build_variant_id + || record.property_list_id, + ); + const pricingContexts = usesVendorPricingNamespace ? undefined : pricingContextsForUsage(session, record); + if (record.pricing_option_id && pricingContexts && !pricingContexts.packagePricingOptionIds.has(record.pricing_option_id)) { + errors.push({ + code: 'INVALID_PRICING_OPTION', + message: `pricing_option_id "${record.pricing_option_id}" is not part of media buy "${record.media_buy_id}".`, + field: `usage[${i}].pricing_option_id`, + }); + continue; + } + const revenueShare = record.pricing_option_id + ? pricingContexts?.revenueShares.find(context => context.pricingOptionId === record.pricing_option_id) + : pricingContexts?.revenueShares[0]; + if (revenueShare) { + if (!record.pricing_option_id) { + errors.push({ + code: 'INVALID_USAGE_DATA', + message: `pricing_option_id is required for revenue-share reconciliation; expected ${revenueShare.pricingOptionId}.`, + field: `usage[${i}].pricing_option_id`, + }); + continue; + } + if (record.commissionable_value === undefined) { + errors.push({ + code: 'INVALID_USAGE_DATA', + message: 'commissionable_value is required for revenue-share reconciliation.', + field: `usage[${i}].commissionable_value`, + }); + continue; + } + if (record.currency !== revenueShare.currency) { + errors.push({ + code: 'INVALID_USAGE_DATA', + message: `currency must match the selected revenue-share pricing option (${revenueShare.currency}).`, + field: `usage[${i}].currency`, + }); + continue; + } + const expectedCost = roundCurrency(record.commissionable_value * revenueShare.rate, record.currency); + if (Math.abs(roundCurrency(record.vendor_cost, record.currency) - expectedCost) > Number.EPSILON) { + errors.push({ + code: 'INVALID_USAGE_DATA', + message: `vendor_cost must equal round_currency(commissionable_value × commission_rate); expected ${expectedCost}.`, + field: `usage[${i}].vendor_cost`, + }); + continue; + } + const previouslyAcceptedCost = session.usageRecords + .filter(existing => existing.mediaBuyId === record.media_buy_id && existing.pricingOptionId === revenueShare.pricingOptionId) + .reduce((sum, existing) => sum + existing.vendorCost, 0); + const cumulativeCost = roundCurrency(previouslyAcceptedCost + record.vendor_cost, record.currency); + if (cumulativeCost > revenueShare.budget) { + errors.push({ + code: 'INVALID_USAGE_DATA', + message: `cumulative vendor_cost ${cumulativeCost} exceeds the package commission budget ${revenueShare.budget}.`, + field: `usage[${i}].vendor_cost`, + }); + continue; + } + } // Validate creative_id exists if provided if (record.creative_id) { @@ -7910,11 +8123,15 @@ export async function handleReportUsage(args: ToolArgs, ctx: TrainingContext) { // Store the usage record session.usageRecords.push({ account: record.account as import('./types.js').AccountRef, + mediaBuyId: record.media_buy_id, creativeId: record.creative_id, signalAgentSegmentId: record.signal_agent_segment_id, pricingOptionId: record.pricing_option_id, impressions: record.impressions, mediaSpend: record.media_spend, + conversions: record.conversions, + conversionValue: record.conversion_value, + commissionableValue: record.commissionable_value, vendorCost: record.vendor_cost, currency: record.currency, final: record.final, diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 9119257c0b..6761c75bc5 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -198,6 +198,8 @@ export interface ComplyDeliveryAccumulator { clicks: number; reportedSpend: { amount: number; currency: string }; conversions: number; + conversionValue?: number; + commissionableValue?: number; isFinal?: boolean; finalizedAt?: string; measurementWindow?: string; @@ -523,11 +525,15 @@ export interface CreativeWebhookActivityRecord { export interface UsageRecord { account: AccountRef; + mediaBuyId?: string; creativeId?: string; signalAgentSegmentId?: string; pricingOptionId?: string; impressions?: number; mediaSpend?: number; + conversions?: number; + conversionValue?: number; + commissionableValue?: number; vendorCost: number; currency: string; final?: boolean; diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index a36d4650ea..8b5f970834 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -50,7 +50,7 @@ const VALID_CHANNELS = [ ] as const; const VALID_PRICING_MODELS = [ - 'cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'cpa', 'flat_rate', 'time', + 'cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'cpa', 'revenue_share', 'flat_rate', 'time', ] as const; const TEST_AGENT_URL = 'http://localhost:3000/api/training-agent'; @@ -1541,6 +1541,60 @@ describe('get_products handler', () => { } }); + it('discovers contingent revenue-share pricing without classifying it as auction pricing', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const account = { brand: { domain: 'affiliate-filter.example' }, operator: 'affiliate-filter.example' }; + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_product', + params: { + product_id: 'affiliate_contingent_product', + fixture: { + name: 'Affiliate contingent product', + description: 'Content commerce priced as a percentage of settled attributed value.', + delivery_type: 'guaranteed', + channels: ['affiliate'], + format_ids: [{ id: 'display_300x250' }], + }, + }, + }); + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_pricing_option', + params: { + product_id: 'affiliate_contingent_product', + pricing_option_id: 'affiliate_purchase_4pct', + fixture: { + pricing_model: 'revenue_share', + event_type: 'purchase', + event_source_id: 'affiliate_attribution', + commission_rate: 0.04, + currency: 'USD', + commission_basis_description: 'Net merchandise value after discounts and returns.', + }, + }, + }); + + const { result: contingentResult } = await simulateCallTool(server, 'get_products', { + account, + buying_mode: 'wholesale', + filters: { pricing_structures: ['contingent'] }, + }); + const contingent = contingentResult.products as Array<{ product_id: string; pricing_options: Array> }>; + expect(contingent).toHaveLength(1); + expect(contingent[0].product_id).toBe('affiliate_contingent_product'); + expect(contingent[0].pricing_options).toHaveLength(1); + expect(contingent[0].pricing_options[0].pricing_model).toBe('revenue_share'); + + const { result: auctionResult } = await simulateCallTool(server, 'get_products', { + account, + buying_mode: 'wholesale', + filters: { is_fixed_price: false }, + }); + const auction = auctionResult.products as Array<{ product_id: string }>; + expect(auction.some(product => product.product_id === 'affiliate_contingent_product')).toBe(false); + }); + it('keeps fixed-price filtering when brief mode falls back to suggestions', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const { result } = await simulateCallTool(server, 'get_products', { @@ -5132,6 +5186,63 @@ describe('report_usage handler', () => { }); } + async function setupRevenueShareBuy(server: ReturnType, budget = 10000) { + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_product', + params: { + product_id: 'affiliate_usage_product', + fixture: { + name: 'Affiliate usage product', + description: 'Content commerce with settled revenue-share billing.', + delivery_type: 'guaranteed', + channels: ['affiliate'], + format_ids: [{ id: 'display_300x250' }], + reporting_capabilities: { + available_metrics: ['conversions', 'conversion_value', 'commissionable_value', 'spend'], + }, + }, + }, + }); + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'seed_pricing_option', + params: { + product_id: 'affiliate_usage_product', + pricing_option_id: 'affiliate_purchase_4pct', + fixture: { + pricing_model: 'revenue_share', + event_type: 'purchase', + event_source_id: 'affiliate_attribution', + commission_rate: 0.04, + currency: 'USD', + commission_basis_description: 'Net merchandise value after discounts and returns.', + }, + }, + }); + await simulateCallTool(server, 'sync_event_sources', { + account, + event_sources: [{ + event_source_id: 'affiliate_attribution', + name: 'Affiliate attribution', + event_types: ['purchase'], + }], + }); + const { result } = await simulateCallTool(server, 'create_media_buy', { + account, + brand: { domain: 'usage.example' }, + start_time: 'asap', + end_time: '2099-08-31T23:59:59Z', + packages: [{ + product_id: 'affiliate_usage_product', + pricing_option_id: 'affiliate_purchase_4pct', + budget, + }], + }); + expect(result.errors).toBeUndefined(); + return result.media_buy_id as string; + } + it('accepts valid usage for a synced creative', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); await setupCreativeWithPricing(server); @@ -5154,6 +5265,28 @@ describe('report_usage handler', () => { expect(result.rejected).toBeUndefined(); }); + it('keeps vendor pricing_option_id validation when creative usage also names a media buy', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + await setupCreativeWithPricing(server); + const mediaBuyId = await setupRevenueShareBuy(server); + const { result } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [{ + account, + media_buy_id: mediaBuyId, + creative_id: 'cr_usage', + pricing_option_id: 'po_display_300x250_cpm', + impressions: 1000000, + vendor_cost: 200, + currency: 'USD', + }], + }); + + expect(result.accepted).toBe(1); + expect(result.rejected).toBeUndefined(); + }); + it('returns error when reporting_period is missing', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const { result, isError } = await simulateCallTool(server, 'report_usage', { @@ -5333,6 +5466,148 @@ describe('report_usage handler', () => { expect(result.code).toBe('INVALID_USAGE_DATA'); expect(result.message).toContain('non-negative'); }); + + it('accepts revenue-share usage when commission arithmetic matches', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server); + const { result } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [{ + account, + media_buy_id: mediaBuyId, + pricing_option_id: 'affiliate_purchase_4pct', + conversions: 320, + conversion_value: 125000, + commissionable_value: 112500, + vendor_cost: 4500, + currency: 'USD', + }], + }); + + expect(result.accepted).toBe(1); + }); + + it('reports seeded revenue-share delivery without synthesizing auction pacing spend', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server); + await simulateCallTool(server, 'comply_test_controller', { + account, + scenario: 'simulate_delivery', + params: { + media_buy_id: mediaBuyId, + conversions: 320, + conversion_value: 125000, + commissionable_value: 112500, + reported_spend: { amount: 4500, currency: 'USD' }, + is_final: true, + }, + }); + const { result } = await simulateCallTool(server, 'get_media_buy_delivery', { + account, + media_buy_ids: [mediaBuyId], + }); + + const delivery = (result.media_buy_deliveries as Array>)[0]; + const packages = delivery.by_package as Array>; + expect(packages[0]).toMatchObject({ + pricing_model: 'revenue_share', + rate: 0.04, + spend: 4500, + conversions: 320, + conversion_value: 125000, + commissionable_value: 112500, + }); + }); + + it('rejects revenue-share usage when commission arithmetic does not match', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server); + const { result, isError } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [{ + account, + media_buy_id: mediaBuyId, + pricing_option_id: 'affiliate_purchase_4pct', + commissionable_value: 112500, + vendor_cost: 4600, + currency: 'USD', + }], + }); + + expect(isError).toBe(true); + expect(result.code).toBe('INVALID_USAGE_DATA'); + expect(result.field).toBe('usage[0].vendor_cost'); + expect(result.message).toContain('expected 4500'); + }); + + it('rejects revenue-share usage above the package commission budget', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server, 1000); + const { result, isError } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [{ + account, + media_buy_id: mediaBuyId, + pricing_option_id: 'affiliate_purchase_4pct', + commissionable_value: 112500, + vendor_cost: 4500, + currency: 'USD', + }], + }); + + expect(isError).toBe(true); + expect(result.code).toBe('INVALID_USAGE_DATA'); + expect(result.message).toContain('exceeds the package commission budget'); + }); + + it('rejects a pricing option that is not part of the referenced media buy', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server); + const { result, isError } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [{ + account, + media_buy_id: mediaBuyId, + pricing_option_id: 'affiliate_purchase_wrong', + commissionable_value: 112500, + vendor_cost: 4500, + currency: 'USD', + }], + }); + + expect(isError).toBe(true); + expect(result.code).toBe('INVALID_PRICING_OPTION'); + expect(result.field).toBe('usage[0].pricing_option_id'); + }); + + it('enforces the commission budget cumulatively across accepted usage records', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const mediaBuyId = await setupRevenueShareBuy(server, 1000); + const usageRecord = { + account, + media_buy_id: mediaBuyId, + pricing_option_id: 'affiliate_purchase_4pct', + commissionable_value: 15000, + vendor_cost: 600, + currency: 'USD', + }; + const { result, isError } = await simulateCallTool(server, 'report_usage', { + account, + reporting_period: period, + usage: [usageRecord, usageRecord], + }); + + expect(isError).toBeFalsy(); + expect(result.accepted).toBe(1); + const rejected = result.rejected as Array>; + expect(rejected).toHaveLength(1); + expect(rejected[0].code).toBe('INVALID_USAGE_DATA'); + expect(rejected[0].message).toContain('cumulative vendor_cost 1200'); + }); }); // ── update_media_buy handler ─────────────────────────────────────── diff --git a/specs/revenue-share-pricing.md b/specs/revenue-share-pricing.md new file mode 100644 index 0000000000..9036262abe --- /dev/null +++ b/specs/revenue-share-pricing.md @@ -0,0 +1,411 @@ +# Revenue-Share Media Pricing + +**Status**: Proposed — implementation included for review + +## Summary + +AdCP recognizes `affiliate` as a media channel, and implementers are beginning +to bring affiliate use cases to the protocol. Affiliate products are commonly +priced as a percentage of attributed transaction value, but AdCP media-buy +pricing currently supports only fixed monetary prices per unit or period. + +This proposal adds a `revenue_share` media pricing model for a deliberately +narrow first use case: one fixed commission rate applied to the settled, +attributed value of a specified conversion event. + +GitHub issue: #5754 + +## Motivation + +The media channel taxonomy currently describes affiliate as a performance-based +buying model that includes "CPA/CPC/rev-share." CPA and CPC are represented in +the media pricing schemas; revenue share is not. An affiliate brief such as +"4% of each attributed purchase" therefore cannot be expressed faithfully. + +Mapping that commercial term to CPC or CPM loses the actual billing basis. +Treating the commission as out of band lets a transaction proceed, but prevents +buyer agents from comparing the commercial terms, calculating accrued spend, +or reconciling the result through AdCP. + +Existing AdCP surfaces provide much of the required lifecycle already: + +- `event_type` and `event_source_id` identify the billable outcome and its source. +- Delivery reporting carries `conversions`, `conversion_value`, `spend`, + measurement windows, adjustment notifications, and finality. +- `measurement_terms.billing_measurement` names the billing authority and the + window at which its values become invoiceable. +- `report_usage` supports buyer- or vendor-attested billing reconciliation. + +The missing pieces are the pricing model, an unambiguous commission basis, and +rules connecting the selected rate to budget and settlement. + +## Goals + +- Represent a single percentage commission on attributed conversion value. +- Let buyer agents calculate and verify accrued commission spend. +- Reuse AdCP's existing event-source, measurement-authority, adjustment, and + finality contracts. +- Support either a publisher or an affiliate network as the AdCP seller. +- Keep channel and pricing independent: `revenue_share` may be used outside the + `affiliate` channel when the same commercial model applies. + +## Non-goals + +The first version does not standardize: + +- tiered, category-specific, SKU-specific, or new-customer commission rates; +- bonuses, bounties, minimum guarantees, or hybrid fixed-plus-percentage terms; +- currency conversion between transaction value and commission settlement; +- item-level transaction or order feeds; +- disputes or clawbacks after a billing window has been marked final; or +- affiliate tracking links, click IDs, or attribution algorithms. + +Those terms remain out of band or use seller extensions until repeated +implementer demand justifies a portable rule model. + +## Economic model + +The model uses three distinct values: + +| Value | Meaning | +|---|---| +| `conversion_value` | Total value of attributed conversions for analytics. | +| `commissionable_value` | The settled portion of attributed value to which the agreed commission rate applies. | +| `spend` | Commission owed by the buyer for the reporting window. | + +The normative calculation is: + +```text +spend = round_currency(commissionable_value * commission_rate) +``` + +`commissionable_value` is separate from `conversion_value` because a commercial +agreement may exclude taxes, shipping, discounts, returned goods, ineligible +SKUs, or other components of transaction value. The billing authority computes +`commissionable_value` under the parties' agreement. AdCP makes that value and +the arithmetic auditable; it does not attempt to encode every eligibility rule +in the first version. + +`commission_rate` is a decimal proportion from greater than 0 through 1. For +example, `0.04` means 4%. This follows the financial `rate` convention already +used by media-buy cancellation policies and price-breakdown adjustments. The +seller rounds once, after multiplication, to the ISO 4217 minor-unit precision +of `currency`. + +## Proposed pricing option + +```json +{ + "pricing_option_id": "affiliate_purchase_4pct", + "pricing_model": "revenue_share", + "event_type": "purchase", + "event_source_id": "affiliate_attribution", + "commission_rate": 0.04, + "currency": "USD", + "commission_basis_description": "Net merchandise value after discounts, excluding tax and shipping; returns removed before commission lock." +} +``` + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `pricing_option_id` | string | Yes | Unique identifier for this option within the product. | +| `pricing_model` | const `revenue_share` | Yes | Pricing discriminator. | +| `event_type` | EventType | Yes | Conversion event whose attributed value may become commissionable. | +| `custom_event_name` | string | Conditional | Required when `event_type` is `custom`, matching CPA behavior. | +| `event_source_id` | string | Yes | Event source whose attribution is used for billing. | +| `commission_rate` | number, `(0, 1]` | Yes | Decimal proportion applied to `commissionable_value`. | +| `currency` | ISO 4217 string | Yes | Currency of `commissionable_value` and the resulting `spend`. | +| `commission_basis_description` | string | Yes | Human-readable definition of inclusions, exclusions, and return treatment used to calculate `commissionable_value`. | + +The option does not carry `fixed_price`, `floor_price`, `price_guidance`, or +`max_bid`. It is contingent pricing, not fixed-unit pricing and not an auction. + +Only one `commission_rate` applies to a pricing option. A seller that needs two +simple rates can publish separate products or pricing options when each option +has an independently selectable and measurable commission basis. Rule-driven +rate tables remain out of scope. + +## Seller model + +The seller is the party that contracts with the buyer and invoices through +AdCP. That may be: + +- a publisher selling its own affiliate placements; or +- an affiliate network aggregating publisher supply and acting as the sales + agent for the resulting product. + +The pricing model does not require one canonical affiliate topology. Existing +aggregator and delegated-network patterns determine how the seller represents +the underlying supply. + +## Product discovery + +A seller advertises `revenue_share` like any other pricing option: + +```json +{ + "product_id": "pinnacle_affiliate_content", + "name": "Pinnacle content commerce", + "channels": ["affiliate"], + "pricing_options": [ + { + "pricing_option_id": "purchase_4pct", + "pricing_model": "revenue_share", + "event_type": "purchase", + "event_source_id": "pinnacle_affiliate_attribution", + "commission_rate": 0.04, + "currency": "USD", + "commission_basis_description": "Net merchandise value after discounts, excluding tax and shipping; returns removed before commission lock." + } + ], + "reporting_capabilities": { + "available_metrics": [ + "conversions", + "conversion_value", + "commissionable_value", + "spend" + ], + "measurement_windows": [ + { + "window_id": "commission_locked_60d", + "description": "Attributed purchases after the 60-day return and cancellation window", + "duration_days": 60, + "expected_availability_days": 67, + "is_guarantee_basis": true + } + ] + } +} +``` + +A product offering `revenue_share` MUST declare `commissionable_value` and +`spend` in its reporting capabilities. + +### Fixed-versus-auction filtering + +Today `is_fixed_price` treats every pricing option without `fixed_price` as +auction-based. Applying that rule to revenue share would incorrectly require a +`bid_price` and expose the product through an auction filter. + +This proposal introduces an optional discovery filter: + +```json +{ + "filters": { + "pricing_structures": ["fixed", "auction", "contingent"] + } +} +``` + +`revenue_share` has the `contingent` structure. Existing pricing models retain +their current fixed-versus-auction classification. The legacy +`is_fixed_price` filter remains supported, but contingent options match neither +`true` nor `false`; callers seeking contingent pricing use +`pricing_structures`. When `is_fixed_price` is supplied, a seller MUST omit +contingent options from the filtered result. + +This avoids silently classifying revenue share as auction pricing while +preserving existing behavior for current callers. + +## Media-buy commitment and budget + +The buyer selects the pricing option normally and does not send `bid_price`: + +```json +{ + "product_id": "pinnacle_affiliate_content", + "pricing_option_id": "purchase_4pct", + "budget": 10000, + "measurement_terms": { + "billing_measurement": { + "vendor": { "domain": "pinnacle-measurement.example" }, + "measurement_window": "commission_locked_60d", + "finalization_deadline_hours": 168 + } + } +} +``` + +For `revenue_share`, package `budget` is the maximum commission payable, not the +value of attributed sales. The seller MUST NOT invoice above the package budget +without an accepted budget update. Because conversions and returns settle after +delivery, the seller is responsible for managing attribution-tail exposure when +deciding whether to continue or pause the package. + +`pacing` remains optional. It describes the seller's intended delivery pacing, +but agents must not infer that commission accrues evenly during the flight. + +## Reporting and settlement + +### Seller-attested reporting + +Delivery reporting adds `commissionable_value` to the standard metric set. A +settled package row could be: + +```json +{ + "package_id": "pkg_affiliate_001", + "pricing_model": "revenue_share", + "conversions": 320, + "conversion_value": 125000, + "commissionable_value": 112500, + "spend": 4500, + "currency": "USD", + "measurement_window": "commission_locked_60d", + "is_final": true, + "finalized_at": "2026-10-08T18:00:00Z" +} +``` + +The buyer verifies `112500 * 0.04 = 4500`. `conversion_value` remains available +for analytics but is not the billing basis. + +Before finalization, the seller may send `adjusted` notifications for the same +window or a `window_update` in which a later commission-lock window supersedes +an earlier provisional window. Returns and cancellations reduce +`commissionable_value`; the corresponding `spend` is recalculated from the +agreed rate. + +### Buyer- or vendor-attested reporting + +`report_usage` adds optional `conversions`, `conversion_value`, and +`commissionable_value` fields to each usage record. A revenue-share +reconciliation record includes `commissionable_value`: + +```json +{ + "account": { "account_id": "acct_pinnacle_affiliate" }, + "media_buy_id": "mb_affiliate_2026", + "pricing_option_id": "purchase_4pct", + "conversions": 320, + "conversion_value": 125000, + "commissionable_value": 112500, + "vendor_cost": 4500, + "currency": "USD", + "measurement_window": "commission_locked_60d", + "final": true, + "finalized_at": "2026-10-08T18:00:00Z" +} +``` + +For a revenue-share record, `vendor_cost` is the commission owed and MUST equal +`round_currency(commissionable_value * commission_rate)`. +`media_buy_id`, `pricing_option_id`, and `commissionable_value` are required for +this pricing model; `conversions` and `conversion_value` remain optional +analytics fields. + +## Schema and documentation impact + +This implementation updates: + +- `static/schemas/source/enums/pricing-model.json` +- `static/schemas/source/pricing-options/revenue-share-option.json` (new) +- `static/schemas/source/core/pricing-option.json` +- `static/schemas/source/core/delivery-metrics.json` +- `static/schemas/source/enums/available-metric.json` +- `static/schemas/source/account/report-usage-request.json` +- `static/schemas/source/core/product-filters.json` +- the training-agent discovery, commitment, delivery, and reconciliation paths +- a media-buy compliance storyboard covering the end-to-end settlement flow +- generated latest schemas and compliance artifacts +- media pricing, product discovery, delivery reporting, billing-authority, and + channel-taxonomy documentation + +`measurement-terms.json` does not require a structural change. Its description +should be expanded to identify `commissionable_value` as the billing metric for +`revenue_share`. + +## Conformance scenarios + +The implementation should include an end-to-end affiliate storyboard that: + +1. Discovers an affiliate product with a 4% revenue-share option. +2. Creates a package with a commission budget and no `bid_price`. +3. Receives provisional attributed value while the return window is open. +4. Receives an adjustment that reduces commissionable value after a return. +5. Receives a final locked window and verifies `spend` from the selected rate. +6. Reconciles the same result through `report_usage` when the billing authority + is the buyer or a named measurement vendor. +7. Rejects a record whose `vendor_cost` does not match the commission formula. +8. Confirms the package cannot be invoiced above its commission budget without + an accepted budget update. + +Schema tests should also confirm that revenue-share options do not accept +`fixed_price`, `floor_price`, `price_guidance`, `max_bid`, or a buy-time +`bid_price`. + +## Alternatives considered + +### Extend CPA with `commission_rate` + +Rejected. Fixed cost per acquisition and percentage of transaction value are +different billing units. Giving one discriminator two shapes makes discovery, +comparison, and settlement behavior less explicit. + +### Use `price_breakdown.adjustments[kind=commission]` + +Rejected. A price-breakdown commission allocates an already-determined media +price between parties without changing the buyer's committed price. Affiliate +revenue share determines the buyer's price from attributed transaction value. + +### Keep the commission entirely out of band + +Viable for integrations that do not want AdCP billing, but insufficient as the +only protocol answer. It leaves agents unable to discover or compare the actual +commercial term and preserves the current taxonomy/schema inconsistency. + +### Add a generic custom media pricing model + +Deferred. A custom escape hatch could carry affiliate metadata but would not +give agents a portable formula or settlement contract. It is useful only when +the terms require operator review, not as the standard representation of the +common single-rate case. + +### Reuse `conversion_value` directly + +Rejected. Gross attributed value and the contractually eligible commission +basis often differ. Reusing one field would either make billing ambiguous or +silently change the existing analytics metric's meaning. + +## Compatibility and versioning + +The wire additions are additive. Sellers that do not support revenue share omit +the option. Buyers must tolerate the new `pricing_model` enum value under the +protocol's normal additive-enum rules and must not route it through auction +handling. + +The `pricing_structures` filter is additive. Defining contingent options as +outside both values of `is_fixed_price` adds behavior for a class that does not +exist today, but may expose implementations that mechanically equate the +absence of `fixed_price` with auctions. Those implementations must branch on +the pricing model or structure before requiring `bid_price`. This compatibility +point should receive explicit WG review before adoption. + +Target the next protocol minor after WG acceptance. Released schema versions +remain unchanged. + +## Reviewer checklist + +- [ ] A single-rate model covers a real near-term affiliate integration. +- [ ] `commissionable_value` is the correct portable billing basis. +- [ ] Decimal `commission_rate` (`0.04` = 4%) is consistent with media-buy conventions. +- [ ] Requiring `event_source_id` provides enough attribution identity. +- [ ] Existing measurement windows, adjustments, and finality can represent the return-lock lifecycle. +- [ ] Package `budget` should cap commission payable as proposed. +- [ ] Contingent pricing should be excluded from both values of the legacy `is_fixed_price` filter. +- [ ] The deferred rate-rule cases are correctly scoped out of the first version. + +## Open questions for implementers + +1. Which value do current affiliate integrations expose as the commission base: + gross order value, net merchandise value, or an already-computed eligible + amount? +2. Can those integrations publish provisional and locked values through named + measurement windows, including downward adjustments for returns? +3. Do operators need multiple simultaneously selectable rates in the first + version, or can materially different rates be represented as separate + products or pricing options? +4. Is a hard commission budget operationally enforceable given attribution + lag, or does the protocol also need an explicit, buyer-approved overage rule? diff --git a/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml b/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml new file mode 100644 index 0000000000..52621bc250 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml @@ -0,0 +1,341 @@ +id: media_buy_seller/revenue_share_pricing +version: "1.0.0" +title: "Revenue-share pricing and settlement" +category: media_buy_seller +summary: "Verifies contingent revenue-share discovery, commitment without a bid, commissionable-value delivery, and formula-checked usage reconciliation." +track: media_buy +introduced_in: "3.1" +required_tools: + - sync_accounts + - sync_event_sources + - get_products + - create_media_buy + - get_media_buy_delivery + - report_usage + - comply_test_controller + +narrative: | + Affiliate and content-commerce products may charge a percentage of settled + attributed transaction value. The seller exposes a revenue_share pricing + option, the buyer registers the declared event source and creates a package + without bid_price, and the seller reports both analytics conversion_value and + the narrower commissionable_value used for billing. + + The selected rate is 4%. A final commissionable value of USD 112,500 produces + USD 4,500 spend. The same values are submitted through report_usage so the + seller can verify the commission arithmetic and budget cap. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - revenue_share_pricing + examples: + - "Affiliate networks selling content-commerce partnerships" + - "Publishers selling percentage-of-sale performance placements" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The runner seeds one affiliate product with a 4% revenue-share option and a + 60-day commission-lock measurement window. The seller supports the + contingent pricing_structures filter and extended delivery simulation. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "affiliate_content_commerce" + name: "Pinnacle content commerce" + description: "Performance placements priced on settled attributed purchase value." + delivery_type: "guaranteed" + channels: ["affiliate"] + format_ids: + - id: "display_300x250" + reporting_capabilities: + available_metrics: + - conversions + - conversion_value + - commissionable_value + - spend + measurement_windows: + - window_id: "commission_locked_60d" + description: "Attributed purchases after the 60-day return and cancellation window" + duration_days: 60 + expected_availability_days: 67 + is_guarantee_basis: true + pricing_options: + - product_id: "affiliate_content_commerce" + pricing_option_id: "affiliate_purchase_4pct" + pricing_model: "revenue_share" + event_type: "purchase" + event_source_id: "affiliate_attribution" + commission_rate: 0.04 + currency: "USD" + commission_basis_description: "Net merchandise value after discounts, excluding tax and shipping; returns removed before commission lock." + +phases: + - id: setup + title: "Establish the account and attribution source" + steps: + - id: sync_accounts + title: "Establish account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + expected: | + Return an active account for the buyer and brand. + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + idempotency_key: "$generate:uuid_v4#revenue_share_pricing_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json schema" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a platform-assigned ID" + + - id: sync_event_sources + title: "Register affiliate attribution" + task: sync_event_sources + schema_ref: "media-buy/sync-event-sources-request.json" + response_schema_ref: "media-buy/sync-event-sources-response.json" + doc_ref: "/media-buy/task-reference/sync_event_sources" + stateful: true + expected: | + Register the event_source_id declared by the pricing option so the + seller has an authoritative purchase-event binding. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + event_sources: + - event_source_id: "affiliate_attribution" + name: "Affiliate attribution" + event_types: ["purchase"] + allowed_domains: ["acmeoutdoor.example"] + idempotency_key: "$generate:uuid_v4#revenue_share_pricing_sync_event_sources" + validations: + - check: response_schema + description: "Response matches sync-event-sources-response.json schema" + - check: field_value + path: "event_sources[0].event_source_id" + value: "affiliate_attribution" + description: "Seller returns the registered attribution source" + + - id: discovery + title: "Discover contingent pricing" + steps: + - id: get_revenue_share_products + title: "Filter for contingent products" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + stateful: true + expected: | + Return the seeded affiliate product and only its revenue_share option. + The option exposes its event source, rate, currency, and commission basis. + sample_request: + buying_mode: "wholesale" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + filters: + pricing_structures: ["contingent"] + fields: + - product_id + - name + - channels + - pricing_options + - reporting_capabilities + validations: + - check: response_schema + description: "Response matches get-products-response.json schema" + - check: field_value + path: "products[0].product_id" + value: "affiliate_content_commerce" + description: "The affiliate product is returned" + - check: field_value + path: "products[0].pricing_options[0].pricing_model" + value: "revenue_share" + description: "The returned option uses revenue-share pricing" + - check: field_value + path: "products[0].pricing_options[0].commission_rate" + value: 0.04 + description: "The commission rate is a decimal proportion" + - check: field_value + path: "products[0].pricing_options[0].event_source_id" + value: "affiliate_attribution" + description: "The billing event source is explicit" + + - id: commitment + title: "Commit a revenue-share package without a bid" + steps: + - id: create_revenue_share_buy + title: "Create revenue-share media buy" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + stateful: true + expected: | + Accept the package without bid_price. Budget is the maximum payable + commission, not attributed transaction value. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + start_time: "asap" + end_time: "2099-08-31T23:59:59Z" + packages: + - product_id: "affiliate_content_commerce" + pricing_option_id: "affiliate_purchase_4pct" + budget: 10000 + measurement_terms: + billing_measurement: + vendor: + domain: "pinnacle-measurement.example" + measurement_window: "commission_locked_60d" + finalization_deadline_hours: 168 + idempotency_key: "$generate:uuid_v4#revenue_share_pricing_create_media_buy" + context_outputs: + - key: "media_buy_id" + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json schema" + - check: field_present + path: "media_buy_id" + description: "Seller returns a media buy ID" + - check: field_absent + path: "packages[0].bid_price" + description: "Contingent pricing does not produce a bid price" + + - id: settlement + title: "Report and reconcile settled commission" + steps: + - id: simulate_locked_commission + title: "Inject final commission-lock delivery" + task: comply_test_controller + requires_tool: comply_test_controller + stateful: true + expected: | + Record final attributed values and USD 4,500 spend for a 4% rate on + USD 112,500 commissionable value. + sample_request: + account: + sandbox: true + scenario: "simulate_delivery" + params: + media_buy_id: "$context.media_buy_id" + conversions: 320 + conversion_value: 125000 + commissionable_value: 112500 + reported_spend: + amount: 4500 + currency: "USD" + is_final: true + finalized_at: "2099-10-08T18:00:00Z" + measurement_window: "commission_locked_60d" + validations: + - check: field_value + path: "success" + value: true + description: "Final commission delivery simulation succeeds" + + - id: get_locked_commission + title: "Verify final commissionable value and spend" + task: get_media_buy_delivery + schema_ref: "media-buy/get-media-buy-delivery-request.json" + response_schema_ref: "media-buy/get-media-buy-delivery-response.json" + doc_ref: "/media-buy/task-reference/get_media_buy_delivery" + stateful: true + expected: | + Return the settled commissionable value and spend with finality and + measurement-window metadata. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: ["$context.media_buy_id"] + validations: + - check: response_schema + description: "Response matches get-media-buy-delivery-response.json schema" + - check: field_value + path: "media_buy_deliveries[0].by_package[0].pricing_model" + value: "revenue_share" + description: "Package retains the revenue-share pricing model" + - check: field_value + path: "media_buy_deliveries[0].by_package[0].commissionable_value" + value: 112500 + description: "Package reports the settled commission basis" + - check: field_value + path: "media_buy_deliveries[0].by_package[0].spend" + value: 4500 + description: "Spend equals 4% of commissionable value" + - check: field_value + path: "media_buy_deliveries[0].by_package[0].is_final" + value: true + description: "Commission-lock delivery is final" + - check: field_value + path: "media_buy_deliveries[0].by_package[0].measurement_window" + value: "commission_locked_60d" + description: "Delivery identifies the commission-lock window" + + - id: report_revenue_share_usage + title: "Submit formula-verifiable usage" + task: report_usage + schema_ref: "account/report-usage-request.json" + response_schema_ref: "account/report-usage-response.json" + doc_ref: "/accounts/tasks/report_usage" + stateful: true + expected: | + Accept the record after verifying vendor_cost equals the selected + commission rate multiplied by commissionable_value and remains within budget. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + idempotency_key: "$generate:uuid_v4#revenue_share_pricing_report_usage" + reporting_period: + start: "2099-07-01T00:00:00Z" + end: "2099-08-31T23:59:59Z" + usage: + - account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_id: "$context.media_buy_id" + pricing_option_id: "affiliate_purchase_4pct" + conversions: 320 + conversion_value: 125000 + commissionable_value: 112500 + vendor_cost: 4500 + currency: "USD" + final: true + finalized_at: "2099-10-08T18:00:00Z" + measurement_window: "commission_locked_60d" + validations: + - check: response_schema + description: "Response matches report-usage-response.json schema" + - check: field_value + path: "accepted" + value: 1 + description: "Formula-valid revenue-share usage is accepted" diff --git a/static/schemas/source/account/report-usage-request.json b/static/schemas/source/account/report-usage-request.json index c91b944520..ac3c1ecef4 100644 --- a/static/schemas/source/account/report-usage-request.json +++ b/static/schemas/source/account/report-usage-request.json @@ -60,6 +60,21 @@ "minimum": 0, "description": "Media spend in currency for the period. Required when a percent_of_media pricing model was used, so the vendor can verify the applied rate." }, + "conversions": { + "type": "number", + "minimum": 0, + "description": "Number of attributed conversion events for the reporting period. Optional analytics context for revenue_share reconciliation." + }, + "conversion_value": { + "type": "number", + "minimum": 0, + "description": "Total monetary value of attributed conversions for the reporting period, in currency. Optional analytics context for revenue_share reconciliation; it is not the billing basis." + }, + "commissionable_value": { + "type": "number", + "minimum": 0, + "description": "Settled attributed value eligible for commission, in currency. Required when pricing_option_id selects a revenue_share option. The receiver verifies vendor_cost = round_currency(commissionable_value × the selected commission_rate)." + }, "signal_agent_segment_id": { "type": "string", "description": "Signal identifier from get_signals. Required for signals agents.", diff --git a/static/schemas/source/compliance/comply-test-controller-request.json b/static/schemas/source/compliance/comply-test-controller-request.json index 5852996426..91c05f3340 100644 --- a/static/schemas/source/compliance/comply-test-controller-request.json +++ b/static/schemas/source/compliance/comply-test-controller-request.json @@ -690,6 +690,16 @@ "minimum": 0, "description": "Conversions to simulate. Used by simulate_delivery." }, + "conversion_value": { + "type": "number", + "minimum": 0, + "description": "Total attributed conversion value to simulate in the delivery reporting currency. Used by simulate_delivery." + }, + "commissionable_value": { + "type": "number", + "minimum": 0, + "description": "Settled attributed value eligible for revenue-share commission to simulate in the delivery reporting currency. Used by simulate_delivery." + }, "reported_spend": { "type": "object", "description": "Spend as reported in delivery data. Does not affect budget. Used by simulate_delivery.", diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index 50eb761896..a193f5095f 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -52,6 +52,11 @@ "description": "Total monetary value of attributed conversions (in the reporting currency)", "minimum": 0 }, + "commissionable_value": { + "type": "number", + "description": "Settled portion of attributed conversion value eligible for revenue-share commission, in the reporting currency. For revenue_share pricing, spend = round_currency(commissionable_value × commission_rate). This is distinct from conversion_value because taxes, shipping, discounts, returns, cancellations, or ineligible items may be excluded under the agreed commission basis.", + "minimum": 0 + }, "roas": { "type": "number", "description": "Return on ad spend (conversion_value / spend)", diff --git a/static/schemas/source/core/measurement-terms.json b/static/schemas/source/core/measurement-terms.json index 113b5dfda1..8c68a7d015 100644 --- a/static/schemas/source/core/measurement-terms.json +++ b/static/schemas/source/core/measurement-terms.json @@ -7,7 +7,7 @@ "properties": { "billing_measurement": { "type": "object", - "description": "Which vendor's count of the billing metric governs invoicing. The billing metric is determined by the pricing_model on the selected pricing_option (e.g., impressions for CPM, completed views for CPCV).", + "description": "Which vendor's value for the billing metric governs invoicing. The billing metric is determined by the pricing_model on the selected pricing_option (e.g., impressions for CPM, completed views for CPCV, commissionable_value for revenue_share).", "properties": { "vendor": { "$ref": "/schemas/core/brand-ref.json", diff --git a/static/schemas/source/core/pricing-option.json b/static/schemas/source/core/pricing-option.json index df22c8139b..7350ed5577 100644 --- a/static/schemas/source/core/pricing-option.json +++ b/static/schemas/source/core/pricing-option.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/pricing-option.json", "title": "Pricing Option", - "description": "A pricing model option offered by a publisher for a product. Discriminated by pricing_model field. If fixed_price is present, it's fixed pricing. If absent, it's auction-based (floor_price and price_guidance optional). Bid-based auction models may also include max_bid as a boolean signal to interpret bid_price as a buyer ceiling instead of an exact honored price.", + "description": "A pricing model option offered by a publisher for a product, discriminated by pricing_model. Unit-based models with fixed_price use fixed pricing; unit-based models without fixed_price use auction pricing with optional floor_price and price_guidance. revenue_share is contingent pricing: its payable spend is calculated from settled commissionable_value and does not use fixed_price or bid_price. Bid-based auction models may include max_bid to interpret bid_price as a buyer ceiling instead of an exact honored price.", "discriminator": { "propertyName": "pricing_model" }, @@ -28,6 +28,9 @@ { "$ref": "/schemas/pricing-options/cpa-option.json" }, + { + "$ref": "/schemas/pricing-options/revenue-share-option.json" + }, { "$ref": "/schemas/pricing-options/flat-rate-option.json" }, diff --git a/static/schemas/source/core/product-filters.json b/static/schemas/source/core/product-filters.json index c2e6741ecb..d41ecb006a 100644 --- a/static/schemas/source/core/product-filters.json +++ b/static/schemas/source/core/product-filters.json @@ -14,7 +14,17 @@ }, "is_fixed_price": { "type": "boolean", - "description": "Filter by pricing availability and returned pricing options: true = products offering fixed pricing (at least one option with fixed_price), false = products offering auction pricing (at least one option without fixed_price). Products with both fixed and auction options match both true and false, but sellers MUST return only the pricing_options entries matching the requested pricing type so buyers can deterministically select from the returned options." + "description": "Legacy filter for fixed versus auction pricing availability. true returns options with fixed_price; false returns auction options whose price is established through bid_price. Contingent options such as revenue_share match neither value and MUST be omitted whenever this filter is present. Use pricing_structures to discover contingent pricing. Products with both fixed and auction options match both true and false, but sellers MUST return only entries matching the requested structure." + }, + "pricing_structures": { + "type": "array", + "description": "Filter by how the payable price is determined. fixed selects options with fixed_price, auction selects options established through bid_price, and contingent selects options calculated from a measured business outcome after delivery (currently revenue_share). Products match when at least one pricing option has a requested structure. Sellers MUST return only matching pricing_options entries. When combined with is_fixed_price, both filters apply and the returned entries must satisfy both.", + "items": { + "$ref": "/schemas/enums/pricing-structure.json" + }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" }, "pricing_currencies": { "type": "array", diff --git a/static/schemas/source/enums/available-metric.json b/static/schemas/source/enums/available-metric.json index 982a4d5158..f21dd004c4 100644 --- a/static/schemas/source/enums/available-metric.json +++ b/static/schemas/source/enums/available-metric.json @@ -14,6 +14,7 @@ "completion_rate", "conversions", "conversion_value", + "commissionable_value", "roas", "cost_per_acquisition", "new_to_brand_rate", diff --git a/static/schemas/source/enums/pricing-model.json b/static/schemas/source/enums/pricing-model.json index 7a92e2c262..a8115723fb 100644 --- a/static/schemas/source/enums/pricing-model.json +++ b/static/schemas/source/enums/pricing-model.json @@ -12,6 +12,7 @@ "cpv", "cpp", "cpa", + "revenue_share", "flat_rate", "time" ], @@ -23,6 +24,7 @@ "cpv": "Cost Per View - cost per view at publisher-defined threshold (e.g., 50% completion)", "cpp": "Cost Per Point - cost per Gross Rating Point or Target Rating Point (TV/audio)", "cpa": "Cost Per Acquisition - cost per conversion event (purchase, lead, signup, etc.)", + "revenue_share": "Revenue Share - decimal commission rate applied to settled commissionable conversion value", "flat_rate": "Flat Rate - fixed cost regardless of delivery volume (sponsorships, takeovers)", "time": "Time - cost per time unit (hour, day, week, or month) that scales with campaign duration" } diff --git a/static/schemas/source/enums/pricing-structure.json b/static/schemas/source/enums/pricing-structure.json new file mode 100644 index 0000000000..6bfcb8b708 --- /dev/null +++ b/static/schemas/source/enums/pricing-structure.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/pricing-structure.json", + "title": "Pricing Structure", + "description": "How the payable price is determined. Fixed pricing has a seller-declared monetary unit price, auction pricing is established through a buyer bid, and contingent pricing is calculated from a measured business outcome after delivery.", + "type": "string", + "enum": [ + "fixed", + "auction", + "contingent" + ], + "enumDescriptions": { + "fixed": "Seller-declared monetary unit price through fixed_price", + "auction": "Buyer bid against optional floor_price and price_guidance", + "contingent": "Price calculated from a measured business outcome after delivery" + } +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index ed105bb19e..f991bc58e7 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -727,6 +727,10 @@ "$ref": "/schemas/enums/pricing-model.json", "description": "Supported pricing models for advertising products" }, + "pricing-structure": { + "$ref": "/schemas/enums/pricing-structure.json", + "description": "How a payable media price is determined: fixed, auction, or contingent" + }, "delivery-type": { "$ref": "/schemas/enums/delivery-type.json", "description": "Type of inventory delivery" @@ -1110,7 +1114,7 @@ } }, "pricing-options": { - "description": "Individual pricing model schemas. Discriminated by pricing_model field. If fixed_price is present, it's fixed pricing. If absent, it's auction-based (floor_price and price_guidance optional). Bid-based auction models may also include max_bid as a boolean signal to interpret bid_price as a buyer ceiling instead of an exact honored price.", + "description": "Individual pricing model schemas discriminated by pricing_model. Unit-based models may be fixed or auction-based. Contingent models such as revenue_share calculate payable spend from a measured business outcome after delivery.", "schemas": { "cpm-option": { "$ref": "/schemas/pricing-options/cpm-option.json", @@ -1140,6 +1144,10 @@ "$ref": "/schemas/pricing-options/cpa-option.json", "description": "Cost Per Acquisition (CPA) pricing for performance campaigns - fixed price per conversion event" }, + "revenue-share-option": { + "$ref": "/schemas/pricing-options/revenue-share-option.json", + "description": "Revenue-share pricing - decimal commission rate applied to settled commissionable conversion value" + }, "flat-rate-option": { "$ref": "/schemas/pricing-options/flat-rate-option.json", "description": "Flat rate pricing for DOOH and sponsorships - supports fixed rate and auction modes" diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-response.json b/static/schemas/source/media-buy/get-media-buy-delivery-response.json index 47fc090acd..56753e380a 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-response.json @@ -112,6 +112,11 @@ "description": "Total conversion value across all media buys (if applicable)", "minimum": 0 }, + "commissionable_value": { + "type": "number", + "description": "Total settled conversion value eligible for revenue-share commission across all media buys (if applicable)", + "minimum": 0 + }, "roas": { "type": "number", "description": "Aggregate return on ad spend across all media buys (total conversion_value / total spend)", @@ -303,7 +308,7 @@ }, "rate": { "type": "number", - "description": "The pricing rate for this package in the specified currency. For fixed-rate pricing, this is the agreed rate (e.g., CPM rate of 12.50 means $12.50 per 1,000 impressions). For auction-based pricing, this represents the effective rate based on actual delivery.", + "description": "The pricing rate for this package. For fixed-rate pricing, this is the agreed currency-denominated unit rate (e.g., CPM rate of 12.50 means $12.50 per 1,000 impressions). For auction-based pricing, this is the effective rate based on actual delivery. For revenue_share, this is the decimal commission rate (e.g., 0.04 means 4%) and is not itself currency-denominated.", "minimum": 0 }, "currency": { @@ -582,6 +587,11 @@ "description": "Daily conversion value for this package", "minimum": 0 }, + "commissionable_value": { + "type": "number", + "description": "Daily settled conversion value eligible for revenue-share commission for this package", + "minimum": 0 + }, "roas": { "type": "number", "description": "Daily return on ad spend (conversion_value / spend)", @@ -710,6 +720,11 @@ "description": "Daily conversion value", "minimum": 0 }, + "commissionable_value": { + "type": "number", + "description": "Daily settled conversion value eligible for revenue-share commission", + "minimum": 0 + }, "roas": { "type": "number", "description": "Daily return on ad spend (conversion_value / spend)", diff --git a/static/schemas/source/pricing-options/revenue-share-option.json b/static/schemas/source/pricing-options/revenue-share-option.json new file mode 100644 index 0000000000..8c1b91b0ca --- /dev/null +++ b/static/schemas/source/pricing-options/revenue-share-option.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/pricing-options/revenue-share-option.json", + "title": "Revenue Share Pricing Option", + "description": "Revenue-share pricing. The advertiser pays a fixed decimal commission rate applied to settled commissionable_value attributed to the declared conversion event and event source. Spend is rounded once to the ISO 4217 minor-unit precision of currency after multiplication: spend = round_currency(commissionable_value × commission_rate). This is contingent pricing, not fixed-unit pricing or an auction.", + "type": "object", + "properties": { + "pricing_option_id": { + "type": "string", + "minLength": 1, + "description": "Unique identifier for this pricing option within the product" + }, + "pricing_model": { + "type": "string", + "const": "revenue_share", + "description": "Percentage commission on settled attributed conversion value" + }, + "event_type": { + "allOf": [{ "$ref": "/schemas/enums/event-type.json" }], + "description": "Conversion event whose attributed value may become commissionable (for example purchase or subscribe)" + }, + "custom_event_name": { + "type": "string", + "minLength": 1, + "description": "Name of the custom event when event_type is custom. Required for custom events and absent otherwise." + }, + "event_source_id": { + "type": "string", + "minLength": 1, + "description": "Event source whose attribution is authoritative for billing. Must match an event source configured through sync_event_sources." + }, + "commission_rate": { + "type": "number", + "description": "Decimal proportion applied to commissionable_value. For example, 0.04 means 4%.", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "currency": { + "type": "string", + "description": "ISO 4217 currency code for commissionable_value and resulting spend", + "pattern": "^[A-Z]{3}$", + "examples": ["USD", "EUR", "GBP", "JPY"] + }, + "commission_basis_description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Human-readable definition of inclusions, exclusions, and return or cancellation treatment used by the billing authority to calculate commissionable_value." + } + }, + "required": [ + "pricing_option_id", + "pricing_model", + "event_type", + "event_source_id", + "commission_rate", + "currency", + "commission_basis_description" + ], + "allOf": [ + { + "if": { + "properties": { + "event_type": { "const": "custom" } + }, + "required": ["event_type"] + }, + "then": { + "required": ["custom_event_name"] + }, + "else": { + "not": { "required": ["custom_event_name"] } + } + }, + { + "not": { + "anyOf": [ + { "required": ["fixed_price"] }, + { "required": ["floor_price"] }, + { "required": ["price_guidance"] }, + { "required": ["max_bid"] } + ] + } + } + ], + "additionalProperties": true +} diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 0a416ba7d4..f622dc3c40 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -1719,6 +1719,95 @@ async function runTests() { return true; }); + // Test 12C: Revenue-share pricing is contingent and formula-verifiable + await test('Revenue-share pricing validates its contingent billing contract', async () => { + const testAjv = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + discriminator: true, + loadSchema: loadExternalSchema + }); + addFormats(testAjv); + + const validatePricing = await testAjv.compileAsync( + loadSchema(path.join(SCHEMA_BASE_DIR, 'core/pricing-option.json')) + ); + const base = { + pricing_option_id: 'affiliate_purchase_4pct', + pricing_model: 'revenue_share', + event_type: 'purchase', + event_source_id: 'affiliate_attribution', + commission_rate: 0.04, + currency: 'USD', + commission_basis_description: 'Net merchandise value after discounts; returns removed before commission lock.' + }; + + if (!validatePricing(base)) { + return `valid revenue-share option failed: ${validatePricing.errors.map(err => `${err.instancePath} ${err.message}`).join('; ')}`; + } + + const invalidCases = [ + [{ ...base, commission_rate: 0 }, 'zero commission rate'], + [{ ...base, commission_rate: 1.01 }, 'commission rate above one'], + [{ ...base, event_source_id: undefined }, 'missing event source'], + [{ ...base, commission_basis_description: undefined }, 'missing commission basis'], + [{ ...base, fixed_price: 4 }, 'fixed_price on contingent pricing'], + [{ ...base, floor_price: 1 }, 'floor_price on contingent pricing'], + [{ ...base, custom_event_name: 'purchase_complete' }, 'custom event name on non-custom event'], + [{ ...base, event_type: 'custom' }, 'custom event without custom_event_name'] + ]; + for (const [value, label] of invalidCases) { + if (validatePricing(value)) return `${label} unexpectedly validated`; + } + + const custom = { ...base, event_type: 'custom', custom_event_name: 'qualified_purchase' }; + if (!validatePricing(custom)) { + return `custom revenue-share event failed: ${validatePricing.errors.map(err => `${err.instancePath} ${err.message}`).join('; ')}`; + } + + const validateFilters = await testAjv.compileAsync( + loadSchema(path.join(SCHEMA_BASE_DIR, 'core/product-filters.json')) + ); + if (!validateFilters({ pricing_structures: ['contingent'] })) { + return `contingent pricing filter failed: ${validateFilters.errors.map(err => `${err.instancePath} ${err.message}`).join('; ')}`; + } + if (validateFilters({ pricing_structures: ['variable'] })) { + return 'unknown pricing structure unexpectedly validated'; + } + + const availableMetric = loadSchema(path.join(SCHEMA_BASE_DIR, 'enums/available-metric.json')); + if (!availableMetric.enum.includes('commissionable_value')) { + return 'commissionable_value is missing from available-metric enum'; + } + + const validateUsage = await testAjv.compileAsync( + loadSchema(path.join(SCHEMA_BASE_DIR, 'account/report-usage-request.json')) + ); + const usage = { + idempotency_key: '3c1f7987-b2dc-4ee9-a391-2f761d8aca4c', + reporting_period: { + start: '2026-07-01T00:00:00Z', + end: '2026-07-31T23:59:59Z' + }, + usage: [{ + account: { account_id: 'acct_affiliate' }, + media_buy_id: 'mb_affiliate', + pricing_option_id: 'affiliate_purchase_4pct', + conversions: 320, + conversion_value: 125000, + commissionable_value: 112500, + vendor_cost: 4500, + currency: 'USD' + }] + }; + if (!validateUsage(usage)) { + return `revenue-share usage failed: ${validateUsage.errors.map(err => `${err.instancePath} ${err.message}`).join('; ')}`; + } + + return true; + }); + // Test 13: Validate schema examples against their schemas await test('Schema examples validate against their own schemas', async () => { // Skip schemas that require format-aware validation (creative manifests need format context)