diff --git a/.changeset/billing-did-you-mean-lookup-key.md b/.changeset/billing-did-you-mean-lookup-key.md new file mode 100644 index 0000000000..b6f0c94038 --- /dev/null +++ b/.changeset/billing-did-you-mean-lookup-key.md @@ -0,0 +1,6 @@ +--- +--- + +Surface `did_you_mean` in Addie billing tool responses when the LLM passes a non-canonical `lookup_key` alias (e.g. `explorer_annual` instead of `aao_membership_explorer_50`). Tighten tool descriptions on `create_payment_link`, `send_invoice`, and `confirm_send_invoice` to explicitly forbid constructing the key from tier name and billing interval. Closes #2550. + +Server-side Addie change only — no protocol schema changes. diff --git a/.changeset/docs-media-buy-lifecycle-page.md b/.changeset/docs-media-buy-lifecycle-page.md new file mode 100644 index 0000000000..7fa8a57f2f --- /dev/null +++ b/.changeset/docs-media-buy-lifecycle-page.md @@ -0,0 +1,6 @@ +--- +--- + +Add new canonical Media Buy Lifecycle Flow page (`docs/media-buy/media-buys/lifecycle.mdx`) covering the sequenced task flow, full state machine diagram, guaranteed/PG deal IO acceptance path, and creative sync timing. Adds nav entries in both navigation tabs, a link in the quickstart What's next section, and a link from `sales-agent-creative-capabilities.mdx`. Closes #2570. + +Docs-only change — no protocol schema changes. diff --git a/docs.json b/docs.json index 736a13c432..1b180193c4 100644 --- a/docs.json +++ b/docs.json @@ -190,6 +190,7 @@ "docs/media-buy/product-discovery/collections-and-installments", "docs/media-buy/product-discovery/refinement", "docs/media-buy/media-buys/index", + "docs/media-buy/media-buys/lifecycle", "docs/media-buy/media-buys/optimization-reporting", "docs/media-buy/media-buys/policy-compliance", "docs/media-buy/creatives/index", @@ -696,6 +697,7 @@ "docs/media-buy/product-discovery/collections-and-installments", "docs/media-buy/product-discovery/refinement", "docs/media-buy/media-buys/index", + "docs/media-buy/media-buys/lifecycle", "docs/media-buy/media-buys/optimization-reporting", "docs/media-buy/media-buys/policy-compliance", "docs/media-buy/creatives/index", diff --git a/docs/creative/sales-agent-creative-capabilities.mdx b/docs/creative/sales-agent-creative-capabilities.mdx index f0ce9e3e20..cd1a33f7b0 100644 --- a/docs/creative/sales-agent-creative-capabilities.mdx +++ b/docs/creative/sales-agent-creative-capabilities.mdx @@ -311,3 +311,4 @@ if (managesCreatives) { - [Generative Creative](/docs/creative/generative-creative) — Using `build_creative` for AI-powered generation - [get_creative_delivery](/docs/creative/task-reference/get_creative_delivery) — Variant-level delivery reporting - [get_media_buy_delivery](/docs/media-buy/task-reference/get_media_buy_delivery) — Media buy delivery reporting +- [Media Buy Lifecycle](/docs/media-buy/media-buys/lifecycle) — State machine, sequenced flow, guaranteed deal IO path, creative sync timing diff --git a/docs/media-buy/media-buys/lifecycle.mdx b/docs/media-buy/media-buys/lifecycle.mdx new file mode 100644 index 0000000000..cedaef7298 --- /dev/null +++ b/docs/media-buy/media-buys/lifecycle.mdx @@ -0,0 +1,170 @@ +--- +title: Media Buy Lifecycle Flow +description: "Step-by-step sequence from product discovery through delivery, including the guaranteed-deal IO acceptance path and creative sync timing." +"og:title": "AdCP — Media Buy Lifecycle Flow" +--- + +This page is the canonical sequence reference for media buy lifecycle. For conceptual background on the full lifecycle — campaign structure, package model, property targeting, and async operations — see [Media Buy Lifecycle](/docs/media-buy/media-buys/). + +## Standard flow + +Every media buy follows four steps: + +```mermaid +flowchart TD + A[get_products] --> B[create_media_buy] + B --> C{initial state} + C -->|creatives missing| D[pending_creatives] + C -->|creatives present,\nflight not yet started| E[pending_start] + C -->|creatives present,\nflight started| F[active] + D --> G[sync_creatives] + G --> E + E --> F + F --> H{delivery} + H -->|budget exhausted /\ngoal met / flight ended| I[completed] + H -->|buyer or seller action| J[paused] + J --> F +``` + +1. **`get_products`** — discover available inventory matching your brief. +2. **`create_media_buy`** — submit packages; the seller validates and confirms. +3. **`sync_creatives`** — assign creative assets to packages that need them. +4. **Delivery** — the buy enters `active`, accrues impressions, and eventually reaches a terminal state. + +## State machine + +### Media buy states + +| State | Meaning | Terminal? | +|-------|---------|-----------| +| `pending_creatives` | Approved; no creatives assigned yet | No | +| `pending_start` | Creatives assigned; waiting for flight date | No | +| `active` | Delivering impressions | No | +| `paused` | Temporarily halted | No | +| `completed` | Flight ended, goal met, or budget exhausted | Yes | +| `rejected` | Seller declined the buy | Yes | +| `canceled` | Buyer or seller terminated before completion | Yes | + + +`pending_manual` and `pending_permission` are **task-level** statuses — they describe whether the *operation* (e.g., `create_media_buy`) is queued for human review, not the media buy's own state. The media buy enters `pending_creatives`, `pending_start`, or `active` once the operation completes. See [Asynchronous Operations](/docs/media-buy/media-buys/#asynchronous-operations-and-human-in-the-loop). + + +### Transitions + +```mermaid +stateDiagram-v2 + [*] --> pending_creatives : create_media_buy\n(no creatives) + [*] --> pending_start : create_media_buy\n(creatives present,\nflight future) + [*] --> active : create_media_buy\n(creatives present,\nflight started) + + pending_creatives --> pending_start : sync_creatives + pending_start --> active : flight date reached + + active --> paused : update_media_buy\n(paused: true) + paused --> active : update_media_buy\n(paused: false) + + active --> completed : flight ended /\ngoal met / budget exhausted + paused --> completed : flight ended /\ngoal met / budget exhausted + + pending_creatives --> rejected : seller declines + pending_start --> rejected : seller declines + + pending_creatives --> canceled : update_media_buy\n(canceled: true) + pending_start --> canceled : update_media_buy\n(canceled: true) + active --> canceled : update_media_buy\n(canceled: true) + paused --> canceled : update_media_buy\n(canceled: true) + + completed --> [*] + rejected --> [*] + canceled --> [*] +``` + +### Discovering valid actions at runtime + +Rather than hardcoding the state machine, read `valid_actions` from `get_media_buys`. The seller returns exactly what the buyer can do in the current state: + +```json +{ + "media_buy_id": "mb_12345", + "status": "active", + "revision": 3, + "valid_actions": ["pause", "cancel", "update_budget", "update_dates", "update_packages", "add_packages", "sync_creatives"] +} +``` + +Always pass `revision` in `update_media_buy` calls. The seller rejects with `CONFLICT` if the revision has changed since your last read. + +## Guaranteed / PG deal variation + +Products with `delivery_type: "guaranteed"` require contractual commitment before delivery begins. The flow diverges after `create_media_buy`: + +```mermaid +flowchart TD + A[get_products\ndelivery_type: guaranteed] --> B[create_media_buy\nwith accountability_terms] + B --> C{task status} + C -->|IO signing needed| D[submitted\ntask_id returned] + C -->|IO pre-signed| E[pending_creatives\nor pending_start] + D --> F[IO signed\nout-of-band] + F --> E + E --> G[sync_creatives\nif needed] + G --> H[active — guaranteed delivery] + H --> I{performance} + I -->|standards met| J[completed] + I -->|under-delivery| K[makegood / remediation] + K --> J +``` + +### What makes a guaranteed buy different + +**`accountability_terms` are required** on each package with a guaranteed product. Three fields are required: + +- `performance_standards` — viewability, IVT, completion rate, and other thresholds with measurement vendor +- `measurement_terms` — who counts the billing metric, acceptable variance, and makegood remedies +- `cancellation_policy` — notice period and cancellation fee for early termination + +Omitting any of these on a guaranteed package causes the seller to return `TERMS_REJECTED`. + +**IO signing** — `create_media_buy` for a guaranteed product may return task status `submitted` with a `task_id` rather than completing synchronously. This means the seller's system is awaiting insertion order (IO) acceptance. Poll with `tasks/get` or configure a webhook. Once the IO is signed, the completion artifact carries the `media_buy_id` and the media buy enters `pending_creatives` or `pending_start`. + +**Makegoods** — if the seller under-delivers against agreed `performance_standards`, they propose a remedy from the `makegood_policy`: `additional_delivery`, `credit`, or `invoice_adjustment`. The buyer accepts or disputes. + + +A seller who accepts without under-delivering earns a favorable accountability signal. See [Accountability](/docs/media-buy/advanced-topics/accountability) for the full negotiation flow including how buyers can propose non-default terms at `create_media_buy` time. + + +## Creative sync timing + +### When creatives are required + +`create_media_buy` accepts inline `creative_assignments` or `creatives` per package. If you supply them at creation time and the flight date has passed, the buy enters `active` directly. If the flight date is in the future, it enters `pending_start`. + +If no creatives are assigned at creation, the buy enters `pending_creatives`. Delivery cannot begin until `sync_creatives` is called to assign at least one creative per package. + +### The `creative_deadline` + +`create_media_buy` returns a `creative_deadline` timestamp on the media buy response. Individual packages may carry their own `creative_deadline`. **Package-level deadlines take precedence over the media buy deadline.** This matters for mixed-channel orders — a print package may have a material deadline days before the digital packages in the same buy. + +After the deadline, `sync_creatives` calls for that package return `CREATIVE_REJECTED`. Creative changes are blocked; delivery continues with whatever creatives are currently assigned (or the package remains in `pending_creatives` if none were ever assigned). + +``` +Deadline hierarchy: + package.creative_deadline (if present — wins) + ↓ else + media_buy.creative_deadline +``` + +### Effect on creatives when a buy ends + +When a media buy reaches `rejected`, `canceled`, or `completed`, creative assignments are released. The creatives themselves are not deleted — they remain in the library with their existing review status and are available for assignment to other media buys. + + +Creative library state and creative assignment state are tracked independently. A creative that was assigned to a canceled buy still has whatever review status it earned and can be immediately assigned to a new buy. See [creative state and assignment state](/docs/creative/creative-libraries#creative-state-and-assignment-state-are-separate). + + +## See also + +- [Media Buy Lifecycle](/docs/media-buy/media-buys/) — full lifecycle reference: campaign structure, package model, async operations +- [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) — task reference with request parameters, response shapes, and examples +- [`sync_creatives`](/docs/creative/task-reference/sync_creatives) — assign and update creative assets on active packages +- [Accountability](/docs/media-buy/advanced-topics/accountability) — performance standards, measurement terms, makegood resolution +- [Optimization & Reporting](/docs/media-buy/media-buys/optimization-reporting) — delivery monitoring, dimensional reporting, campaign updates diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index dbabb9d4de..dfdd3f0f3a 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -299,6 +299,7 @@ console.log(result.data.products); - **[Compliance Catalog](/docs/building/compliance-catalog)** — the domains and specialisms an agent can claim, and the storyboards that verify each claim - **[MCP integration guide](/docs/building/integration/mcp-guide)** — transport, sessions, auth details - **[A2A integration guide](/docs/building/integration/a2a-guide)** — streaming, artifacts, push notifications +- **[Media Buy Lifecycle](/docs/media-buy/media-buys/lifecycle)** — state machine, sequenced flow, guaranteed deal IO path, creative sync timing - **[Task reference](/docs/media-buy/task-reference)** — all available tasks with testable examples - **[Error handling](/docs/building/implementation/transport-errors)** — error codes, recovery strategies - **[Authentication](/docs/building/integration/authentication)** — production credential setup diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index 9d2418ce2a..1004846fa3 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -28,7 +28,7 @@ import { loadRules } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.04.5'; +export const CODE_VERSION = '2026.04.6'; // Types export interface ConfigVersion { diff --git a/server/src/addie/mcp/billing-tools.ts b/server/src/addie/mcp/billing-tools.ts index a5f70d1d66..b461db22f6 100644 --- a/server/src/addie/mcp/billing-tools.ts +++ b/server/src/addie/mcp/billing-tools.ts @@ -62,13 +62,15 @@ You should ask about their company type and approximate revenue to find the righ The link is issued to the signed-in member only — the customer email and identity are taken from the authenticated session, never from caller-supplied input. The member must be signed in at agenticadvertising.org and have a workspace; if not, refuse and direct them to sign up first. -This tool cannot generate payment links on behalf of other people or organizations.`, +This tool cannot generate payment links on behalf of other people or organizations. +IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier +name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`, input_schema: { type: 'object' as const, properties: { lookup_key: { type: 'string', - description: 'The product lookup key from find_membership_products', + description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value', }, }, required: ['lookup_key'], @@ -79,13 +81,15 @@ This tool cannot generate payment links on behalf of other people or organizatio description: `Preview an invoice for the authenticated member's own organization so they can confirm the amount and billing email before it is sent. The contact email and company are taken from the signed-in session, never from caller-supplied input. After calling this and the member confirms, -call confirm_send_invoice to send.`, +call confirm_send_invoice to send. +IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier +name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`, input_schema: { type: 'object' as const, properties: { lookup_key: { type: 'string', - description: 'The product lookup key from find_membership_products', + description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value', }, coupon_id: { type: 'string', @@ -105,13 +109,15 @@ call confirm_send_invoice to send.`, description: `Send an invoice for the authenticated member's own organization after they have confirmed the details shown by send_invoice. The contact email, company, and billing address come from the signed-in session — they cannot be overridden. The org must already have a billing address -on file (set via the dashboard or invite-acceptance flow).`, +on file (set via the dashboard or invite-acceptance flow). +IMPORTANT: Pass lookup_key verbatim from find_membership_products. Do NOT construct it from the tier +name and billing interval (e.g. do not pass "explorer_annual" — pass "aao_membership_explorer_50").`, input_schema: { type: 'object' as const, properties: { lookup_key: { type: 'string', - description: 'The product lookup key from find_membership_products', + description: 'The exact lookup_key value returned by find_membership_products — do not construct or guess this value', }, coupon_id: { type: 'string', @@ -261,13 +267,14 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): logger.info({ lookupKey, orgId, workosUserId }, 'Addie: Creating payment link for signed-in member'); try { - const priceId = await getPriceByLookupKey(lookupKey); - if (!priceId) { + const priceResult = await getPriceByLookupKey(lookupKey); + if (!priceResult) { return JSON.stringify({ success: false, error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`, }); } + const { priceId, canonicalKey } = priceResult; const org = await orgDb.getOrganization(orgId); @@ -305,6 +312,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): success: true, payment_url: session.url, message: 'Payment link created. Share this URL with the signed-in member to complete checkout.', + ...(canonicalKey !== lookupKey && { did_you_mean: canonicalKey }), }); } catch (error) { logger.error({ error }, 'Addie: Error creating payment link'); @@ -357,6 +365,15 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): logger.info({ lookupKey, orgId, hasCoupon: !!effectiveCouponId }, 'Addie: Previewing invoice for signed-in member'); try { + const priceResult = await getPriceByLookupKey(lookupKey); + if (!priceResult) { + return JSON.stringify({ + success: false, + error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`, + }); + } + const { canonicalKey } = priceResult; + const preview = await validateInvoiceDetails({ lookupKey, contactEmail: memberEmail, @@ -385,6 +402,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): discount_description: orgDiscount, discount_warning: preview.discountWarning, payment_terms: paymentTerms ?? 30, + ...(canonicalKey && canonicalKey !== lookupKey && { did_you_mean: canonicalKey }), }); } catch (error) { logger.error({ error }, 'Addie: Error previewing invoice'); @@ -455,6 +473,15 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): ); try { + const priceResult = await getPriceByLookupKey(lookupKey); + if (!priceResult) { + return JSON.stringify({ + success: false, + error: `No product matches lookup_key "${lookupKey}". Call find_membership_products first, then pass the exact lookup_key from the result.`, + }); + } + const { canonicalKey } = priceResult; + const result = await createAndSendInvoice({ lookupKey, companyName: org.name, @@ -480,6 +507,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null): discount_applied: result.discountApplied, discount_description: orgDiscount, discount_warning: result.discountWarning, + ...(canonicalKey && canonicalKey !== lookupKey && { did_you_mean: canonicalKey }), }); } catch (error) { logger.error({ error }, 'Addie: Error sending invoice'); diff --git a/server/src/billing/stripe-client.ts b/server/src/billing/stripe-client.ts index 19eb39ea65..18cf0598ae 100644 --- a/server/src/billing/stripe-client.ts +++ b/server/src/billing/stripe-client.ts @@ -278,13 +278,6 @@ export async function getInvoiceableProducts(): Promise { * full list of valid keys. Silently picking "first match wins" would risk * charging the wrong price. * - * TODO(#2550): This resolver is a band-aid. The root fix is in the Addie - * tool layer: tighten `find_membership_products` / `create_payment_link` - * tool descriptions so the LLM passes lookup_key verbatim, and surface a - * `did_you_mean` field in the tool response so the model learns the - * canonical key. As soon as the catalog gains a monthly Explorer SKU, - * `explorer_annual` will collide here and stop resolving — track interval - * preservation in #2550 too. */ export function resolveLookupKeyAlias(input: string, products: BillingProduct[]): BillingProduct | undefined { const trimmed = input.trim().toLowerCase(); @@ -308,9 +301,13 @@ export function resolveLookupKeyAlias(input: string, products: BillingProduct[]) } /** - * Get a specific price by lookup key + * Get a specific price by lookup key. + * Returns both the Stripe price ID and the canonical lookup key — the canonical + * key differs from the input only when the input was an alias (e.g. "explorer_annual" + * resolves to "aao_membership_explorer_50"). Callers can surface the canonical key to + * the LLM so subsequent calls use it verbatim. */ -export async function getPriceByLookupKey(lookupKey: string): Promise { +export async function getPriceByLookupKey(lookupKey: string): Promise<{ priceId: string; canonicalKey: string } | null> { if (!stripe) { logger.warn({ lookupKey }, 'getPriceByLookupKey: Stripe not initialized'); return null; @@ -320,7 +317,7 @@ export async function getPriceByLookupKey(lookupKey: string): Promise p.lookup_key === lookupKey); if (cachedProduct) { logger.info({ lookupKey, priceId: cachedProduct.price_id }, 'getPriceByLookupKey: Found price in cache'); - return cachedProduct.price_id; + return { priceId: cachedProduct.price_id, canonicalKey: lookupKey }; } // Direct Stripe lookup as fallback when cache doesn't have the key @@ -333,7 +330,7 @@ export async function getPriceByLookupKey(lookupKey: string): Promise 0) { logger.info({ lookupKey, priceId: prices.data[0].id }, 'getPriceByLookupKey: Found price via direct Stripe lookup'); - return prices.data[0].id; + return { priceId: prices.data[0].id, canonicalKey: lookupKey }; } } catch (error) { logger.error({ err: error, lookupKey }, 'getPriceByLookupKey: Error in direct Stripe lookup'); @@ -345,7 +342,7 @@ export async function getPriceByLookupKey(lookupKey: string): Promise p.lookup_key).filter(Boolean); @@ -986,14 +983,15 @@ export async function createAndSendInvoice( } // Get price ID from lookup key - const priceId = await getPriceByLookupKey(data.lookupKey); + const priceResult = await getPriceByLookupKey(data.lookupKey); - if (!priceId) { + if (!priceResult) { logger.error({ lookupKey: data.lookupKey, }, 'No price found for lookup key'); return null; } + const priceId = priceResult.priceId; let subscriptionId: string | undefined; try { @@ -1310,11 +1308,12 @@ export async function validateInvoiceDetails(data: { return null; } - const priceId = await getPriceByLookupKey(data.lookupKey); - if (!priceId) { + const priceResult = await getPriceByLookupKey(data.lookupKey); + if (!priceResult) { logger.error({ lookupKey: data.lookupKey }, 'validateInvoiceDetails: No price found'); return null; } + const priceId = priceResult.priceId; try { const price = await stripe.prices.retrieve(priceId, { expand: ['product'] });