diff --git a/docs/paid-ads-launch-implementation.md b/docs/paid-ads-launch-implementation.md index 4815aa39..675eb81c 100644 --- a/docs/paid-ads-launch-implementation.md +++ b/docs/paid-ads-launch-implementation.md @@ -43,8 +43,40 @@ last thirty days) and the landing page: telling the founder where the event snippet goes; they run the launch again once conversions record. This is the hard block. - `needs_billing`, `needs_link`, `account_disabled`, `campaign_exists`, - `landing_page_unreachable`, `not_recommended`: a `SETUP.md` note is published and the run ends - failed with that reason, since a review-pinned run cannot succeed without an approval. + `landing_page_unreachable`, `not_recommended`, `unsupported_currency`: a `SETUP.md` note is + published and the run ends failed with that reason, since a review-pinned run cannot succeed + without an approval. + +## Currency + +Google reads every amount in the account's own currency (the `amount_micros` fields of +[`CampaignBudget`](https://developers.google.com/google-ads/api/reference/rpc/v25/CampaignBudget) +and the bidding strategies). Tin's figures start in dollars: the assessment's bids come from a +USD account, and the launch's budget and ceiling inputs are in USD. `ads_currency` bridges the +two once: + +- `gather` reads the account's `currency_code`. For USD nothing is converted. For any other + currency it reads the day's [ECB reference rates](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), + derives units per dollar through the euro and receipts the result as `gather:fx`. A failed + read is a retryable error that writes nothing; a retry reuses the receipt once it exists. +- `plan_skeleton()` works out the budget, ceiling, allowable and target cost per customer in + dollars as before, then converts each once and rounds to Google's minimum unit for the + currency (whole yen, won, rupiah; cents elsewhere). The dollar floors (a $1 budget, a $0.05 + ceiling) stay floors at the converted rate. The plan carries `currency_code` and `fx` (rate, + source, date), and `PLAN.md` shows every amount in the account's currency with its dollar + equivalent and the rate used. The founder approves the converted figures. +- Nothing converts again. `campaign.json` carries the same `fx`, and the monitor compares the + account's own costs with the converted allowable cost; proposals carry `currency_code` and + the pinned rate, so `budget_body` and `bidding_body` check the unit and floors in the + account's currency. +- Supported currencies are the ones Google Ads bills in and the ECB publishes (`ads_currency. + MINIMUM_UNIT_MICROS`). Any other, or an account that reports none, gates as + `unsupported_currency` before any rate read, model call or write. +- `apply` refuses a plan whose currency is not the account's. A plan drafted before currencies + were pinned (no `fx`) is read as a USD plan, which is what it was. +- The monitor reads the account's currency each run. A campaign planned in one currency in an + account billing in another (only possible for launches from before currencies were pinned) + gets a `currency_mismatch` alert, plain cost reporting and no cost-based pauses or proposals. ## The plan and the one approval @@ -85,7 +117,7 @@ and `campaign.json` (what the monitor consumes) publish under `ads/google/{run}/ ## The monitor `ads.monitor` runs on demand, daily or weekly against a launch whose campaign row is -`live`. It reads the campaign over seven, fourteen and thirty days, the search terms, keywords +`live`. It reads the account's currency, the campaign over seven, fourteen and thirty days, the search terms, keywords with quality scores, ad policy status, assets, conversion actions and recommendation subscriptions, each a zero-cost receipt. `paid_ads_monitor.decide()` is deterministic: diff --git a/src/tin_lite/ads_currency.py b/src/tin_lite/ads_currency.py new file mode 100644 index 00000000..c7df4299 --- /dev/null +++ b/src/tin_lite/ads_currency.py @@ -0,0 +1,200 @@ +"""Money in a Google Ads account's own currency. + +Tin's campaign figures start in US dollars: the assessment's keyword bids come from a USD +account and the launch inputs are in USD. Google reads every amount in the account's +currency. A launch converts once, at the European Central Bank's reference rate for that day, +rounds to Google's minimum unit for the currency, and pins the rate on the plan the founder +approves. Nothing converts again afterwards: the monitor compares the account's own costs with +the figures pinned at launch, so rate movements never shift a target silently. + +Only currencies both Google Ads bills in and the ECB publishes are supported; any other is a +named refusal, never a guessed rate. +""" + +from __future__ import annotations + +import re +from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal, InvalidOperation + +import httpx + +ECB_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" +ECB_SOURCE = "ECB euro foreign exchange reference rates" +# Google Ads' minimum unit per currency in micros (developers.google.com/google-ads/api/data/ +# codes-formats#currency-codes), limited to the currencies the ECB publishes a rate for. +MINIMUM_UNIT_MICROS = { + "AUD": 10_000, + "BRL": 10_000, + "CAD": 10_000, + "CHF": 10_000, + "CNY": 10_000, + "CZK": 10_000, + "DKK": 10_000, + "EUR": 10_000, + "GBP": 10_000, + "HKD": 10_000, + "HUF": 10_000, + "IDR": 1_000_000, + "ILS": 10_000, + "INR": 10_000, + "JPY": 1_000_000, + "KRW": 1_000_000, + "MXN": 10_000, + "MYR": 10_000, + "NOK": 10_000, + "NZD": 10_000, + "PHP": 10_000, + "PLN": 10_000, + "RON": 10_000, + "SEK": 10_000, + "SGD": 10_000, + "THB": 10_000, + "TRY": 10_000, + "USD": 10_000, + "ZAR": 10_000, +} +SYMBOLS = { + "USD": "$", + "EUR": "€", + "GBP": "£", + "JPY": "¥", + "INR": "₹", + "KRW": "₩", + "ILS": "₪", + "AUD": "A$", + "CAD": "CA$", + "NZD": "NZ$", + "HKD": "HK$", + "SGD": "S$", + "MXN": "MX$", + "BRL": "R$", +} +USD_FX = {"currency_code": "USD", "usd_rate": "1", "source": "identity", "date": None} +_MICROS = Decimal(1_000_000) +_CUBE = re.compile(r"") +_TIME = re.compile(r" bool: + return isinstance(code, str) and code in MINIMUM_UNIT_MICROS + + +def unit(code: str) -> Decimal: + return Decimal(MINIMUM_UNIT_MICROS[code]) / _MICROS + + +def unit_micros(code: str) -> int: + return MINIMUM_UNIT_MICROS[code] + + +def code_of(fx: dict | None) -> str: + return (fx or USD_FX)["currency_code"] + + +def parse_ecb(text: str) -> dict: + """The day's euro rates, with EUR itself at one.""" + day = _TIME.search(text or "") + rates = {code: rate for code, rate in _CUBE.findall(text or "")} + if not day or "USD" not in rates: + raise RateUnavailable("ecb_feed_unreadable") + try: + parsed = {code: Decimal(rate) for code, rate in rates.items()} + except InvalidOperation: + raise RateUnavailable("ecb_feed_unreadable") from None + if any(not value.is_finite() or value <= 0 for value in parsed.values()): + raise RateUnavailable("ecb_feed_unreadable") + return {"date": day.group(1), "rates": {**rates, "EUR": "1"}} + + +def fx_for(code: str, feed: dict) -> dict: + """How many units of `code` one US dollar buys, from one ECB feed (cross rate via EUR).""" + if code == "USD": + return dict(USD_FX) + if not supported(code) or code not in feed["rates"]: + raise RateUnavailable("currency_not_published") + rate = Decimal(feed["rates"][code]) / Decimal(feed["rates"]["USD"]) + return { + "currency_code": code, + "usd_rate": str(rate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)), + "source": ECB_SOURCE, + "date": feed["date"], + } + + +async def read_ecb() -> str: + """One fixed public read; the caller receipts the parsed rate.""" + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client: + response = await client.get(ECB_URL) + except httpx.HTTPError: + raise RateUnavailable("ecb_unreachable") from None + if response.status_code != 200 or len(response.content) > 200_000: + raise RateUnavailable(f"ecb_http_{response.status_code}") + return response.text + + +def convert(usd, fx: dict | None, *, floor_usd=None) -> float: + """A USD figure in the account's currency, on Google's minimum unit and never below + `floor_usd`'s equivalent or one unit.""" + fx = fx or USD_FX + code = fx["currency_code"] + rate, step = Decimal(fx["usd_rate"]), unit(code) + value = (Decimal(str(usd)) * rate).quantize(step, rounding=ROUND_HALF_UP) + if floor_usd is not None: + floor = (Decimal(str(floor_usd)) * rate).quantize(step, rounding=ROUND_CEILING) + value = max(value, floor, step) + return float(value) + + +def round_to_unit(amount, code: str) -> float: + return float(Decimal(str(amount)).quantize(unit(code), rounding=ROUND_HALF_UP)) + + +def in_usd(amount, fx: dict | None) -> float: + fx = fx or USD_FX + return float(Decimal(str(amount)) / Decimal(fx["usd_rate"])) + + +def money(amount, code: str | None, *, cents: bool | None = None) -> str: + """`$20.00`, `€1,250`, `¥3,000`, `TRY 650.00`; whole units for large or unit-less amounts.""" + try: + number = float(amount) + except (TypeError, ValueError): + return "unknown" + if number != number: + return "unknown" + code = code if supported(code) else None + whole = (code is not None and unit(code) >= 1) or (cents is not True and abs(number) >= 100) + text = f"{number:,.0f}" if whole else f"{number:,.2f}" + if code is None: + return f"{text} (account currency)" + symbol = SYMBOLS.get(code) + return f"{symbol}{text}" if symbol else f"{code} {text}" + + +def with_usd(amount, fx: dict | None) -> str: + """The account amount, followed by its dollar equivalent when the account is not in USD.""" + code = code_of(fx) + shown = money(amount, code) + if code == "USD": + return shown + return f"{shown} (about {money(in_usd(amount, fx), 'USD')})" + + +def rate_line(fx: dict | None) -> str: + fx = fx or USD_FX + if fx["currency_code"] == "USD": + return "Amounts are in US dollars, the account's currency." + return ( + f"Amounts are in {fx['currency_code']}, the account's currency, converted once from the " + f"assessment's US dollar figures at {fx['usd_rate']} {fx['currency_code']} per dollar " + f"({fx['source']}, {fx['date']}). Tin never converts them again." + ) diff --git a/src/tin_lite/catalog.py b/src/tin_lite/catalog.py index 81d7733c..211143a2 100644 --- a/src/tin_lite/catalog.py +++ b/src/tin_lite/catalog.py @@ -4214,13 +4214,13 @@ def definition_and_files_with_wiki( "check", "step", "Read the assessment and account", - "link, billing, conversions, tag on the page", + "link, billing, currency, conversions, page tag", ), DiagramNode( "plan", "step", "Set budget, bids and groups", - "code only, up to 5 groups, never broad match", + "code only, account currency at the ECB rate", ), DiagramNode( "tracking", diff --git a/src/tin_lite/db.py b/src/tin_lite/db.py index 7f0021fd..f950b234 100644 --- a/src/tin_lite/db.py +++ b/src/tin_lite/db.py @@ -18,7 +18,7 @@ import asyncpg -from tin_lite import analytics +from tin_lite import ads_currency, analytics from tin_lite.domain import ( PROJECT_TASK_WORKFLOW_NAME, ActivityEvent, @@ -8375,9 +8375,10 @@ def _proposal(row: asyncpg.Record) -> dict[str, Any]: def _proposal_summary(proposal: dict[str, Any]) -> str: proposed = proposal.get("proposed") or {} if proposal.get("kind") == "budget_change": + amount = proposed.get("daily_budget", proposed.get("daily_budget_usd")) return ( f"Proposal {proposal.get('proposal_number')}: change the daily budget to " - f"${proposed.get('daily_budget_usd')}." + f"{ads_currency.money(amount, proposed.get('currency_code') or 'USD', cents=True)}." ) return ( f"Proposal {proposal.get('proposal_number')}: switch bidding to " diff --git a/src/tin_lite/google_ads_requests.py b/src/tin_lite/google_ads_requests.py index 099ca757..76ed3cd4 100644 --- a/src/tin_lite/google_ads_requests.py +++ b/src/tin_lite/google_ads_requests.py @@ -1,7 +1,8 @@ """Pure Google Ads REST request builders and the fixed GAQL set (API v25). Everything here is deterministic and free of I/O so the exact bodies a launch or a monitor -sends can be pinned in tests. Money is expressed as USD and converted to micros strings; enums +sends can be pinned in tests. Money is in the account's currency (see ads_currency) and sent as +micros strings on Google's minimum unit for that currency; enums are the REST string forms; temporary resource ids are negative and unique per bulk request. """ @@ -14,6 +15,8 @@ from decimal import Decimal, InvalidOperation from uuid import UUID +from tin_lite import ads_currency + GEO: dict[str, int] = { "US": 2840, "GB": 2826, @@ -85,6 +88,7 @@ "callouts": 6, "min_budget_usd": Decimal("1"), "min_ceiling_usd": Decimal("0.05"), + "max_amount_usd": Decimal("1000000"), "search_rows": 5000, } CUSTOMER_ID = re.compile(r"^\d{10}$") @@ -104,16 +108,16 @@ def customer_id(value) -> str: return digits -def micros(usd) -> str: - """USD to a micros int64 string; the API rejects fractions of a micro.""" - if isinstance(usd, bool): +def micros(value, *, maximum=Decimal("1000000")) -> str: + """An amount to a micros int64 string; the API rejects fractions of a micro.""" + if isinstance(value, bool): raise ValueError("Amounts must be numbers.") try: - amount = Decimal(str(usd)) + amount = Decimal(str(value)) except (InvalidOperation, ValueError, TypeError): raise ValueError("Amounts must be numbers.") from None - if not amount.is_finite() or amount < 0 or amount > Decimal("1000000"): - raise ValueError("Amounts must be finite, non-negative and below one million dollars.") + if not amount.is_finite() or amount < 0 or amount > Decimal(str(maximum)): + raise ValueError("Amounts must be finite, non-negative and below their ceiling.") return str(int((amount * _MICROS).quantize(Decimal(1)))) @@ -157,10 +161,32 @@ def _url(value, *, label: str) -> str: return value -def _amount(value, *, minimum: Decimal, label: str) -> str: - result = micros(value) - if Decimal(result) < minimum * _MICROS: - raise ValueError(f"{label} must be at least ${minimum}.") +def _fx(fx) -> dict: + fx = fx or ads_currency.USD_FX + if not isinstance(fx, dict) or not ads_currency.supported(fx.get("currency_code")): + raise ValueError("Amounts need a supported account currency.") + try: + rate = Decimal(str(fx.get("usd_rate"))) + except (InvalidOperation, ValueError, TypeError): + raise ValueError("The pinned exchange rate is not a number.") from None + if not rate.is_finite() or rate <= 0: + raise ValueError("The pinned exchange rate must be positive.") + return fx + + +def _amount(value, *, minimum: Decimal, label: str, fx=None) -> str: + """Micros in the account's currency: on its minimum unit, at least `minimum` dollars' + worth at the pinned rate and below the one-million-dollar runaway guard.""" + fx = _fx(fx) + code = fx["currency_code"] + ceiling = ads_currency.convert(BOUNDS["max_amount_usd"], fx) + result = micros(value, maximum=ceiling) + if int(result) % ads_currency.unit_micros(code): + step = ads_currency.money(ads_currency.unit(code), code, cents=True) + raise ValueError(f"{label} must be a whole multiple of {step}.") + floor = ads_currency.convert(minimum, fx, floor_usd=minimum) + if Decimal(result) < Decimal(str(floor)) * _MICROS: + raise ValueError(f"{label} must be at least {ads_currency.money(floor, code, cents=True)}.") return result @@ -202,9 +228,12 @@ def campaign_bundle(plan: dict, *, customer_id: str) -> list[dict]: if not isinstance(plan, dict): raise ValueError("A campaign plan is an object.") names = bundle_names(plan) - budget = _amount(plan.get("daily_budget_usd"), minimum=BOUNDS["min_budget_usd"], label="Budget") + fx = plan.get("fx") or ads_currency.USD_FX + budget = _amount( + plan.get("daily_budget"), minimum=BOUNDS["min_budget_usd"], label="Budget", fx=fx + ) ceiling = _amount( - plan.get("cpc_ceiling_usd"), minimum=BOUNDS["min_ceiling_usd"], label="CPC ceiling" + plan.get("cpc_ceiling"), minimum=BOUNDS["min_ceiling_usd"], label="CPC ceiling", fx=fx ) markets = _list(plan.get("markets"), label="Markets", maximum=len(GEO), minimum=1) if any(not isinstance(m, str) or m not in GEO for m in markets) or len(set(markets)) != len( @@ -554,24 +583,26 @@ def campaign_status_body(campaign_resource: str, status: str) -> tuple[str, dict ) -def budget_body(budget_resource: str, daily_usd) -> tuple[str, dict]: +def budget_body(budget_resource: str, daily, fx=None) -> tuple[str, dict]: rn = _resource(budget_resource, kind="campaignBudgets") - amount = _amount(daily_usd, minimum=BOUNDS["min_budget_usd"], label="Budget") + amount = _amount(daily, minimum=BOUNDS["min_budget_usd"], label="Budget", fx=fx) return "campaignBudgets", _body( [{"update": {"resourceName": rn, "amountMicros": amount}, "updateMask": "amount_micros"}] ) -def bidding_body(campaign_resource: str, strategy: str, target_cpa_usd=None) -> tuple[str, dict]: +def bidding_body( + campaign_resource: str, strategy: str, target_cpa=None, fx=None +) -> tuple[str, dict]: rn = _resource(campaign_resource, kind="campaigns") if strategy == "maximize_conversions": - if target_cpa_usd is not None: + if target_cpa is not None: raise ValueError("Maximise conversions takes no target.") # A bare "maximize_conversions" mask is refused (FIELD_HAS_SUBFIELDS); naming the # target subfield with an empty message switches the strategy without a target. update, mask = {"maximizeConversions": {}}, "maximize_conversions.target_cpa_micros" elif strategy == "target_cpa": - amount = _amount(target_cpa_usd, minimum=BOUNDS["min_ceiling_usd"], label="Target CPA") + amount = _amount(target_cpa, minimum=BOUNDS["min_ceiling_usd"], label="Target CPA", fx=fx) update = {"maximizeConversions": {"targetCpaMicros": amount}} mask = "maximize_conversions.target_cpa_micros" else: @@ -637,7 +668,8 @@ def conversion_action_body( raise ValueError("Counting type is ONE_PER_CLICK or MANY_PER_CLICK.") if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency): raise ValueError("Currency is a three letter code.") - value = Decimal(micros(default_value)) / _MICROS + # A reporting value in the account's currency, not spend: rupiah and won need room. + value = Decimal(micros(default_value, maximum=Decimal("1e11"))) / _MICROS return "conversionActions", _body( [ { diff --git a/src/tin_lite/paid_ads_launch.py b/src/tin_lite/paid_ads_launch.py index 3f049c94..10d86a1d 100644 --- a/src/tin_lite/paid_ads_launch.py +++ b/src/tin_lite/paid_ads_launch.py @@ -19,6 +19,7 @@ from urllib.parse import urlsplit from uuid import UUID +from tin_lite import ads_currency from tin_lite.domain import PAID_ADS_LAUNCH_WORKFLOW_NAME as KEY from tin_lite.model_providers import ModelRoute, ProviderName from tin_lite.organic_audit import MARKETS, digest @@ -38,7 +39,7 @@ ) ROUTES = (COPY_ROUTE,) POLICY = { - "version": 1, + "version": 2, "reasoning_effort": "medium", "repair_reasoning_effort": "low", "max_parallel_calls": 2, @@ -79,6 +80,7 @@ "campaign_exists", "account_disabled", "not_recommended", + "unsupported_currency", ) LAUNCHABLE = {"test", "continue", "go", "restart", "restructure"} LIMITS = { @@ -91,7 +93,20 @@ "keyword": 80, "campaign_name": 80, } -ACRONYMS = {"AI", "API", "SEO", "CRM", "B2B", "B2C", "SAAS", "IOS", "SMS", "PDF", "USD", "UK"} +ACRONYMS = { + "AI", + "API", + "SEO", + "CRM", + "B2B", + "B2C", + "SAAS", + "IOS", + "SMS", + "PDF", + "UK", + *ads_currency.MINIMUM_UNIT_MICROS, +} SUPERLATIVES = ("click here", "best", "#1", "no.1", "number one", "guaranteed", "cheapest") TRACKING_TEMPLATE = "{lpurl}?utm_source=google&utm_medium=cpc&utm_campaign={campaignid}" # The assessment sometimes shapes a group it wants held back until there is purchase @@ -176,12 +191,18 @@ def https_page(value) -> bool: ), "daily_budget_usd": _num( title="Daily budget (USD)", - description="What Google may spend on an average day. Zero uses the assessment's minimum.", + description=( + "What Google may spend on an average day. Zero uses the assessment's minimum. " + "Tin converts it to the account's currency and shows both before you approve." + ), **{"x-tin-ui": {"control": "number", "order": 20}}, ), "cpc_ceiling_usd": _num( title="Most to pay per click (USD)", - description="The bid ceiling for the Maximise clicks strategy. Zero lets Tin compute it.", + description=( + "The bid ceiling for the Maximise clicks strategy, converted like the budget. " + "Zero lets Tin compute it." + ), **{"x-tin-ui": {"control": "number", "order": 21}}, ), "landing_page_url": _text( @@ -384,6 +405,22 @@ def gate(*, assessment: dict, account: dict, site: dict, inputs: dict, existing_ ), "conversion_action": None, } + currency = customer.get("currency_code") + if not ads_currency.supported(currency): + return { + "outcome": "unsupported_currency", + "text": ( + f"The Google Ads account bills in {currency}, and Tin has no daily reference " + "rate for it, so it cannot set a budget or bids it can stand behind. Nothing " + "will be created. Tin supports accounts in " + + ", ".join(sorted(ads_currency.MINIMUM_UNIT_MICROS)) + + "." + if currency + else "Google Ads did not report the account's currency, so Tin cannot set a " + "budget or bids in it. Nothing will be created; run the launch again later." + ), + "conversion_action": None, + } if not (account.get("billing") or {}).get("approved"): return { "outcome": "needs_billing", @@ -509,9 +546,16 @@ def _dedupe_negatives(items, cap: int) -> list[dict]: def plan_skeleton( - *, assessment: dict, keywords_csv: str, inputs: dict, account: dict, marker: str, today: date + *, + assessment: dict, + keywords_csv: str, + inputs: dict, + marker: str, + today: date, + fx: dict | None = None, ) -> dict: - """Everything about the campaign that code decides; the model only adds words.""" + """Everything about the campaign that code decides; the model only adds words. Budget + and bids are worked out in dollars, like the assessment, then converted once at `fx`.""" campaign = assessment.get("campaign") or {} profile = assessment.get("profile") or {} rows = _rows(keywords_csv) @@ -552,6 +596,7 @@ def plan_skeleton( break if len(groups) < POLICY["min_ad_groups"]: raise ValueError("No keyword in the assessment could be placed in an ad group.") + fx = fx or ads_currency.USD_FX budget = _number(inputs.get("daily_budget_usd")) or 0 if budget <= 0: budget = _number((campaign.get("daily_budget_usd") or {}).get("min")) or 0 @@ -562,6 +607,8 @@ def plan_skeleton( by_bid = POLICY["ceiling_bid_multiplier"] * median(bids) if bids else 1.0 ceiling = min(POLICY["ceiling_share_of_budget"] * budget, by_bid) ceiling = max(round(ceiling, 2), 0.05) + allowable = _number(assessment.get("allowable_cpa_usd")) or None + target = _number(campaign.get("target_cpa_usd")) or None landing = str(inputs.get("landing_page_url") or "").strip() or str( campaign.get("landing_page") or "" ) @@ -588,25 +635,47 @@ def plan_skeleton( "campaign_name": name, "marker": marker, "business_name": business, - "daily_budget_usd": budget, - "cpc_ceiling_usd": ceiling, + "currency_code": fx["currency_code"], + "fx": dict(fx), + "daily_budget": ads_currency.convert(budget, fx, floor_usd=1), + "cpc_ceiling": ads_currency.convert(ceiling, fx, floor_usd=0.05), "markets": markets, "landing_page": landing, "start_date": (today + timedelta(days=1)).isoformat(), "tracking_template": TRACKING_TEMPLATE, - "currency_code": (account.get("customer") or {}).get("currency_code") or "USD", "ad_groups": groups, "negatives": _dedupe_negatives(negatives, POLICY["max_negatives"]), "sitelinks": [], "callouts": [], "competitors": competitors, "assessment_run_id": assessment.get("run_id"), - "allowable_cpa_usd": assessment.get("allowable_cpa_usd"), - "target_cpa_usd": campaign.get("target_cpa_usd"), + "allowable_cpa": ads_currency.convert(allowable, fx) if allowable else None, + "target_cpa": ads_currency.convert(target, fx) if target else None, "conversion_event": (assessment.get("inputs") or {}).get("conversion_event"), } +def upgrade_plan(plan: dict) -> dict: + """A plan drafted before amounts carried their currency was worked out and sent in + dollars; read it as a USD plan so an approval waiting across a deploy still applies.""" + if not isinstance(plan, dict) or "fx" in plan: + return plan + upgraded = { + k: v + for k, v in plan.items() + if k not in {"daily_budget_usd", "cpc_ceiling_usd", "allowable_cpa_usd", "target_cpa_usd"} + } + return { + **upgraded, + "currency_code": "USD", + "fx": dict(ads_currency.USD_FX), + "daily_budget": plan.get("daily_budget_usd"), + "cpc_ceiling": plan.get("cpc_ceiling_usd"), + "allowable_cpa": plan.get("allowable_cpa_usd"), + "target_cpa": plan.get("target_cpa_usd"), + } + + # ---------------------------------------------------------------- schemas and prompts @@ -682,7 +751,8 @@ def _plan_for_prompt(plan: dict) -> dict: "campaign_name": plan["campaign_name"], "business_name": plan["business_name"], "landing_page": plan["landing_page"], - "daily_budget_usd": plan["daily_budget_usd"], + "currency_code": plan["currency_code"], + "daily_budget": plan["daily_budget"], "markets": plan["markets"], "ad_groups": [ { @@ -721,13 +791,14 @@ def brief_prompt(plan: dict, assessment: dict): system = f"{WRITING}\n\n{BRIEF_RULES}\n\n{OUTPUT_RULES}" facts = { **_plan_for_prompt(plan), - "cpc_ceiling_usd": plan["cpc_ceiling_usd"], - "monthly_ceiling_usd": round(plan["daily_budget_usd"] * 30.4, 2), + "cpc_ceiling": plan["cpc_ceiling"], + "monthly_ceiling": round(plan["daily_budget"] * 30.4, 2), "negatives": len(plan["negatives"]), "headlines": {g["name"]: g["headlines"] for g in plan["ad_groups"]}, "sitelinks": [s["text"] for s in plan.get("sitelinks") or []], "callouts": plan.get("callouts") or [], - "allowable_cpa_usd": plan.get("allowable_cpa_usd"), + "allowable_cpa": plan.get("allowable_cpa"), + "money_note": ads_currency.rate_line(plan.get("fx")), "assessment_decision": assessment.get("decision"), "assessment_binding_constraint": assessment.get("binding_constraint"), } @@ -1020,11 +1091,11 @@ def _md(value) -> str: return re.sub(r"[\r\n]+", " ", str(value)).strip() -def _money(value) -> str: +def _money(value, plan: dict) -> str: number = _number(value) if number is None: return "unknown" - return f"${number:,.0f}" if number >= 100 else f"${number:,.2f}" + return ads_currency.with_usd(number, plan.get("fx")) def _bounded(documents: dict[str, str], limits: dict[str, int]) -> dict[str, str]: @@ -1049,7 +1120,7 @@ def operation_count(plan: dict) -> int: def render_plan(plan: dict, brief: dict, assessment: dict, inputs: dict) -> dict[str, str]: - monthly = plan["daily_budget_usd"] * 30.4 + monthly = plan["daily_budget"] * 30.4 lines = [ "# Google Ads campaign plan", "", @@ -1063,13 +1134,14 @@ def render_plan(plan: dict, brief: dict, assessment: dict, inputs: dict) -> dict "", "## What it costs", "", - f"- Google may spend up to {_money(plan['daily_budget_usd'])} on an average day, at most about {_money(monthly)} in a month. On a busy day it can spend up to twice the daily amount, never more than the monthly ceiling.", - f"- Tin never pays more than {_money(plan['cpc_ceiling_usd'])} for one click. Google chooses bids below that ceiling to get the most clicks the budget allows.", + f"- Google may spend up to {_money(plan['daily_budget'], plan)} on an average day, at most about {_money(monthly, plan)} in a month. On a busy day it can spend up to twice the daily amount, never more than the monthly ceiling.", + f"- Tin never pays more than {_money(plan['cpc_ceiling'], plan)} for one click. Google chooses bids below that ceiling to get the most clicks the budget allows.", ( - f"- The assessment judged {_money(plan['allowable_cpa_usd'])} an affordable cost per customer; the monitor compares real results to it." - if plan.get("allowable_cpa_usd") + f"- The assessment judged {_money(plan['allowable_cpa'], plan)} an affordable cost per customer; the monitor compares real results to it." + if plan.get("allowable_cpa") else "- The assessment did not set an affordable cost per customer; the monitor reports plain costs." ), + f"- {ads_currency.rate_line(plan.get('fx'))}", "", "## The campaign", "", @@ -1251,7 +1323,7 @@ def render_result(plan: dict, created: dict, review: dict, gate_summary: str) -> "", "## What was created", "", - f"- One Search campaign with a budget of {_money(plan['daily_budget_usd'])} a day and a click ceiling of {_money(plan['cpc_ceiling_usd'])}.", + f"- One Search campaign with a budget of {_money(plan['daily_budget'], plan)} a day and a click ceiling of {_money(plan['cpc_ceiling'], plan)}.", f"- {len(plan['ad_groups'])} ad groups with {sum(len(g['keywords']) for g in plan['ad_groups'])} keywords, one ad each.", f"- {len(plan['negatives'])} negative phrases in a shared list attached to the campaign.", f"- {len(plan.get('sitelinks') or [])} sitelinks and {len(plan.get('callouts') or [])} callouts.", @@ -1283,13 +1355,15 @@ def render_result(plan: dict, created: dict, review: dict, gate_summary: str) -> "campaign_id": created.get("campaign_id"), "budget_id": created.get("budget_id"), "shared_set_id": created.get("shared_set_id"), - "daily_budget_usd": plan["daily_budget_usd"], - "cpc_ceiling_usd": plan["cpc_ceiling_usd"], + "currency_code": plan["currency_code"], + "fx": plan.get("fx"), + "daily_budget": plan["daily_budget"], + "cpc_ceiling": plan["cpc_ceiling"], "markets": plan["markets"], "landing_page": plan["landing_page"], "conversion_action": created.get("conversion_action"), - "allowable_cpa_usd": plan.get("allowable_cpa_usd"), - "target_cpa_usd": plan.get("target_cpa_usd"), + "allowable_cpa": plan.get("allowable_cpa"), + "target_cpa": plan.get("target_cpa"), "enabled": None if unconfirmed else enabled, "enabled_at": created.get("enabled_at"), "created_at": created.get("created_at"), @@ -1318,12 +1392,12 @@ def summary_line(outcome: str, plan: dict | None = None) -> str: if outcome == "ready" and plan: return ( f"Google Ads campaign live: {plan['campaign_name']} at " - f"{_money(plan['daily_budget_usd'])} a day." + f"{_money(plan['daily_budget'], plan)} a day." ) if outcome == "unconfirmed" and plan: return ( f"Google Ads campaign may be running: {plan['campaign_name']} at " - f"{_money(plan['daily_budget_usd'])} a day. Tin keeps watching it." + f"{_money(plan['daily_budget'], plan)} a day. Tin keeps watching it." ) if outcome == "needs_tracking": return "Install tracking, then run the launch again." @@ -1449,6 +1523,7 @@ async def call(name, system, user, schema, max_out): "route_for", "schema_name", "summary_line", + "upgrade_plan", "validate_copy", "validate_tag_install", ] diff --git a/src/tin_lite/paid_ads_launch_activities.py b/src/tin_lite/paid_ads_launch_activities.py index 73cfe96b..2b9846da 100644 --- a/src/tin_lite/paid_ads_launch_activities.py +++ b/src/tin_lite/paid_ads_launch_activities.py @@ -15,7 +15,7 @@ from temporalio import activity from temporalio.exceptions import ApplicationError -from tin_lite import paid_ads, paid_ads_launch +from tin_lite import ads_currency, paid_ads, paid_ads_launch from tin_lite.google_ads_requests import ( QUERIES, SUBSCRIPTION_TYPES, @@ -94,11 +94,20 @@ def _failed(result: dict, action: str, outcome: str) -> str: class PaidAdsLaunchActivities: def __init__( - self, *, database, storage, settings, router=None, integrations=None, site_reader=None + self, + *, + database, + storage, + settings, + router=None, + integrations=None, + site_reader=None, + rate_reader=None, ): self.db, self.storage, self.settings = database, storage, settings self.router, self.integrations = router, integrations self.read_site = site_reader or read_site + self.read_rates = rate_reader or ads_currency.read_ecb # ------------------------------------------------------------ receipts @@ -411,20 +420,6 @@ async def gather(self, run_id: str) -> None: return await self._progress(run_id, "gather", 1, "Reading the assessment and the Ads account") assessment, keywords_csv = await self._assessment(run_id, scope) - try: - skeleton = paid_ads_launch.plan_skeleton( - assessment=assessment, - keywords_csv=keywords_csv, - inputs=scope["inputs"], - account={"customer": {}}, - marker=scope["marker"], - today=datetime.fromisoformat(scope["today"]).date(), - ) - except (ValueError, KeyError, TypeError) as exc: - skeleton = None - skeleton_error = str(exc)[:200] - else: - skeleton_error = None reads = {} for name, query in ( ("account", QUERIES["account"]()), @@ -433,16 +428,6 @@ async def gather(self, run_id: str) -> None: ("subscriptions", QUERIES["subscriptions"]()), ): reads[name] = await self._ads(run_id, f"gather:{name}", "search", {"query": query}) - existing = None - if skeleton is not None: - lookup = await self._ads( - run_id, - "gather:campaign_lookup", - "search", - {"query": QUERIES["campaign_by_name"](bundle_names(skeleton)["campaign"])}, - ) - rows = _rows(lookup) - existing = rows[0].get("campaign") if rows else None health = ads_health_summary( {name: _rows(result) for name, result in reads.items() if name != "subscriptions"} ) @@ -481,6 +466,32 @@ async def gather(self, run_id: str) -> None: non_retryable=reads["account"].get("error") is not None, ) await self._save(run_id, "gathered:account", {"status": "completed", "value": account}) + # Budget and bids are only drafted in a currency Tin can price; the gate names any other. + currency = account["customer"].get("currency_code") + fx = await self._fx(run_id, currency) if ads_currency.supported(currency) else None + skeleton, skeleton_error = None, None + if fx is not None: + try: + skeleton = paid_ads_launch.plan_skeleton( + assessment=assessment, + keywords_csv=keywords_csv, + inputs=scope["inputs"], + marker=scope["marker"], + today=datetime.fromisoformat(scope["today"]).date(), + fx=fx, + ) + except (ValueError, KeyError, TypeError) as exc: + skeleton_error = str(exc)[:200] + existing = None + if skeleton is not None: + lookup = await self._ads( + run_id, + "gather:campaign_lookup", + "search", + {"query": QUERIES["campaign_by_name"](bundle_names(skeleton)["campaign"])}, + ) + rows = _rows(lookup) + existing = rows[0].get("campaign") if rows else None landing = ( skeleton["landing_page"] if skeleton @@ -519,6 +530,27 @@ async def gather(self, run_id: str) -> None: {"gate": gate, "skeleton": skeleton, "existing_campaign": existing}, ) + async def _fx(self, run_id: str, currency: str) -> dict: + """The day's rate for the account's currency, read once and receipted so a retry or a + later step uses the same rate the founder approves.""" + if currency == "USD": + return dict(ads_currency.USD_FX) + saved = await self._result(run_id, "gather:fx") + if saved: + return saved["value"] + try: + fx = ads_currency.fx_for(currency, ads_currency.parse_ecb(await self.read_rates())) + except ads_currency.RateUnavailable as exc: + # Nothing was written; the activity retries and reads the rate again. + raise ApplicationError( + f"Tin could not read the day's {currency} exchange rate ({exc.code}). " + "Nothing was changed.", + non_retryable=False, + ) from None + return (await self._save(run_id, "gather:fx", {"status": "completed", "value": fx}))[ + "value" + ] + @activity.defn(name="paid_ads_launch_draft") async def draft(self, run_id: str) -> str: """Returns the mode as a small control fact: launch, tracking or setup.""" @@ -531,7 +563,6 @@ async def draft(self, run_id: str) -> str: run = await self._active(run_id) project = await self.db.get_project(run.project_id) assessment, _ = await self._assessment(run_id, scope) - account = (await self._result(run_id, "gathered:account"))["value"] site = ((await self._result(run_id, "gather:site")) or {}).get("value") or {} business = str((assessment.get("profile") or {}).get("business_name") or "the business") await self._progress(run_id, "draft", 2, "Drafting the campaign for your approval") @@ -542,10 +573,11 @@ async def generate(step, system, user, schema, max_out, effort): return await self._model(run_id, step, system, user, schema, max_out, effort) record: dict = {"mode": None, "gate": gate} + fx = ((await self._result(run_id, "gather:fx")) or {}).get("value") or ads_currency.USD_FX if gate["outcome"] == "ready": record["mode"] = "launch" evidence = { - "plan": gathered["skeleton"], + "plan": paid_ads_launch.upgrade_plan(gathered["skeleton"]), "assessment": assessment, "readable": site.get("readable") or [], "site_text": evidence_text(site) if site.get("pages") else "", @@ -576,8 +608,11 @@ async def generate(step, system, user, schema, max_out, effort): "name": paid_ads_launch.conversion_action_name(business, scope["marker"]), "category": category, "counting_type": counting, - "default_value": float(assessment.get("allowable_cpa_usd") or 0) or 1.0, - "currency": account["customer"].get("currency_code") or "USD", + "default_value": ads_currency.convert( + float(assessment.get("allowable_cpa_usd") or 0) or 1.0, + fx, + ), + "currency": ads_currency.code_of(fx), "create": True, } record["action"] = action @@ -605,8 +640,8 @@ async def generate(step, system, user, schema, max_out, effort): source_commit_sha=scope["assessment"]["revision"], plan_path=names[artifact], plan_commit_sha=publication["canonical_commit_sha"], - daily_budget_micros=int(micros(plan["daily_budget_usd"])) if plan else None, - cpc_ceiling_micros=int(micros(plan["cpc_ceiling_usd"])) if plan else None, + daily_budget_micros=int(micros(plan["daily_budget"], maximum=1e12)) if plan else None, + cpc_ceiling_micros=int(micros(plan["cpc_ceiling"], maximum=1e12)) if plan else None, ) await self._save(run_id, "draft", record) return record["mode"] @@ -725,11 +760,11 @@ async def request_review(self, run_id: str) -> None: f"/{publication['artifact_path']}" ) if draft["mode"] == "launch": - plan = draft["plan"] + plan = paid_ads_launch.upgrade_plan(draft["plan"]) summary = ( f"Approve and launch: one Google Search campaign, {plan['campaign_name']}, at " - f"${plan['daily_budget_usd']:.2f} a day in your Google Ads account. Nothing is " - "created until you approve it." + f"{ads_currency.with_usd(plan['daily_budget'], plan['fx'])} a day in your Google " + "Ads account. Nothing is created until you approve it." ) else: action = draft["action"] @@ -785,8 +820,18 @@ async def _refuse(self, run_id: str, code: str, text: str): async def _apply_launch(self, run_id: str, draft: dict) -> None: scope = await self._result(run_id, "scope") - plan = draft["plan"] + plan = paid_ads_launch.upgrade_plan(draft["plan"]) run = await self._active(run_id) + account = (await self._result(run_id, "gathered:account"))["value"] + billed_in = account["customer"].get("currency_code") + if billed_in != plan["currency_code"]: + await self._refuse( + run_id, + "currency", + f"The plan's amounts are in {plan['currency_code']} but the account bills in " + f"{billed_in or 'an unreported currency'}, so nothing was created. Run the " + "launch again to draft it in the account's currency.", + ) operations = campaign_bundle(plan, customer_id=scope["customer_id"]) await self.db.update_paid_ads_campaign(run_id=run.id, status="creating") validated = await self._ads( @@ -866,7 +911,6 @@ async def _apply_launch(self, run_id: str, draft: dict) -> None: str(resources.get("shared_set") or "").rsplit("/", 1)[-1] or None ), ) - account = (await self._result(run_id, "gathered:account"))["value"] enabled_types = sorted( { str(item.get("type")) @@ -1082,8 +1126,9 @@ async def publish(self, run_id: str) -> None: business = str((assessment.get("profile") or {}).get("business_name") or "the business") await self._progress(run_id, "publish", 5, "Saving the result to your project files") if draft["mode"] == "launch": + plan = paid_ads_launch.upgrade_plan(draft["plan"]) documents = paid_ads_launch.render_result( - draft["plan"], + plan, applied, { "summary": ( @@ -1096,7 +1141,7 @@ async def publish(self, run_id: str) -> None: status = "live" event = "paid_ads_launch_ready" summary = paid_ads_launch.summary_line( - "unconfirmed" if applied.get("enable_unconfirmed") else "ready", draft["plan"] + "unconfirmed" if applied.get("enable_unconfirmed") else "ready", plan ) else: rendered = paid_ads_launch.render_tracking( diff --git a/src/tin_lite/paid_ads_monitor.py b/src/tin_lite/paid_ads_monitor.py index dc6fedab..04a09ead 100644 --- a/src/tin_lite/paid_ads_monitor.py +++ b/src/tin_lite/paid_ads_monitor.py @@ -15,7 +15,7 @@ from pathlib import Path from uuid import UUID -from tin_lite import paid_ads +from tin_lite import ads_currency, paid_ads from tin_lite.domain import PAID_ADS_MONITOR_WORKFLOW_NAME from tin_lite.model_providers import ModelRoute @@ -27,7 +27,7 @@ ROUTES: tuple[ModelRoute, ...] = (paid_ads.JUDGMENT_ROUTE, paid_ads.DRAFTING_ROUTE) JUDGMENT_STEPS = frozenset({"brief"}) POLICY = { - "version": 1, + "version": 2, "quiet_days": 3, "search_terms_min_clicks": 2, "keyword_pause_multiple": 3.0, @@ -173,6 +173,7 @@ def check_inputs(inputs: dict) -> None: def _usd(micros) -> float: + """Micros to the account's own currency units; the name predates other currencies.""" try: value = float(micros or 0) except (TypeError, ValueError): @@ -221,11 +222,11 @@ def _window(row: dict) -> dict: return { "impressions": impressions, "clicks": clicks, - "cost_usd": cost, + "cost": cost, "conversions": round(conversions, 2), "ctr": round(clicks / impressions, 4) if impressions else 0.0, - "cpc_usd": round(cost / clicks, 2) if clicks else 0.0, - "cpa_usd": round(cost / conversions, 2) if conversions else None, + "cpc": round(cost / clicks, 2) if clicks else 0.0, + "cpa": round(cost / conversions, 2) if conversions else None, "impression_share": _share(metrics.get("searchImpressionShare")), "budget_lost_share": _share(metrics.get("searchBudgetLostImpressionShare")), "rank_lost_share": _share(metrics.get("searchRankLostImpressionShare")), @@ -249,12 +250,12 @@ def _campaign(row: dict) -> dict: "primary_status": str(campaign.get("primaryStatus") or "UNKNOWN"), "primary_status_reasons": [str(r) for r in campaign.get("primaryStatusReasons") or []], "bidding_strategy_type": str(campaign.get("biddingStrategyType") or "UNKNOWN"), - "cpc_ceiling_usd": _usd(ceiling) if ceiling is not None else None, - "target_cpa_usd": _usd(tcpa) if tcpa is not None else None, + "cpc_ceiling": _usd(ceiling) if ceiling is not None else None, + "target_cpa": _usd(tcpa) if tcpa is not None else None, "ai_max_enabled": bool((campaign.get("aiMaxSetting") or {}).get("enableAiMax")), "migration_dates": dates, - "budget_usd": _usd(budget.get("amountMicros")), - "recommended_budget_usd": ( + "budget": _usd(budget.get("amountMicros")), + "recommended_budget": ( _usd(budget["recommendedBudgetAmountMicros"]) if budget.get("recommendedBudgetAmountMicros") is not None else None @@ -274,7 +275,7 @@ def _search_term(row: dict) -> dict: "source": str((row.get("segments") or {}).get("searchTermMatchSource") or ""), "ad_group_id": str((row.get("adGroup") or {}).get("id") or ""), "clicks": _int(metrics.get("clicks")), - "cost_usd": _usd(metrics.get("costMicros")), + "cost": _usd(metrics.get("costMicros")), "conversions": round(_float(metrics.get("conversions")), 2), } @@ -292,7 +293,7 @@ def _keyword(row: dict) -> dict: "status": str(criterion.get("status") or "UNKNOWN"), "quality_score": _int(quality) if quality is not None else None, "clicks": _int(metrics.get("clicks")), - "cost_usd": _usd(metrics.get("costMicros")), + "cost": _usd(metrics.get("costMicros")), "conversions": round(_float(metrics.get("conversions")), 2), "impressions": _int(metrics.get("impressions")), } @@ -339,8 +340,10 @@ def normalize_reads(reads: dict) -> dict: rows = lambda name: [r for r in (reads.get(name) or []) if isinstance(r, dict)] # noqa: E731 head = _first(reads.get("campaign_7d")) or _first(reads.get("campaign_30d")) terms = [t for t in map(_search_term, rows("search_terms")) if t["term"]] - terms.sort(key=lambda t: (-t["cost_usd"], -t["clicks"], t["term"])) + terms.sort(key=lambda t: (-t["cost"], -t["clicks"], t["term"])) + customer = _first(reads.get("account")).get("customer") or {} return { + "currency_code": customer.get("currencyCode") or None, "campaign": _campaign(head), "windows": { "7d": _window(_first(reads.get("campaign_7d"))), @@ -474,12 +477,23 @@ def _iso_date(value) -> date | None: return None -def _money(value: float) -> float: - return round(float(value) + 1e-9, 2) +def _money(value: float, code: str = "USD") -> float: + rounded = round(float(value) + 1e-9, 2) + return ads_currency.round_to_unit(rounded, code) if ads_currency.supported(code) else rounded + + +def _show(value, code) -> str: + return ads_currency.money(value, code, cents=True) + + +def _fx(campaign: dict) -> dict: + """The rate pinned at launch; a launch from before currencies were pinned ran in dollars.""" + fx = campaign.get("fx") + return fx if isinstance(fx, dict) and fx.get("currency_code") else dict(ads_currency.USD_FX) def _allowable(campaign: dict) -> float | None: - value = campaign.get("allowable_cpa_usd") + value = campaign.get("allowable_cpa" if "fx" in campaign else "allowable_cpa_usd") if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: return None return float(value) @@ -495,19 +509,19 @@ def _term_totals(search_terms: list[dict]) -> list[dict]: { "term": row["term"], "clicks": 0, - "cost_usd": 0.0, + "cost": 0.0, "conversions": 0.0, "excluded": False, }, ) total["clicks"] += row["clicks"] - total["cost_usd"] = _money(total["cost_usd"] + row["cost_usd"]) + total["cost"] = _money(total["cost"] + row["cost"]) total["conversions"] += row["conversions"] total["excluded"] = total["excluded"] or row["status"] in EXCLUDED_TERM_STATES - return sorted(totals.values(), key=lambda t: (-t["cost_usd"], -t["clicks"], t["term"])) + return sorted(totals.values(), key=lambda t: (-t["cost"], -t["clicks"], t["term"])) -def _negatives(reads: dict, labels: dict, cap: int) -> list[dict]: +def _negatives(reads: dict, labels: dict, cap: int, code: str) -> list[dict]: chosen = [] for term in _term_totals(reads["search_terms"]): if len(chosen) >= cap: @@ -524,9 +538,9 @@ def _negatives(reads: dict, labels: dict, cap: int) -> list[dict]: "text": term["term"], "match_type": "PHRASE", "clicks": term["clicks"], - "cost_usd": term["cost_usd"], + "cost": term["cost"], "why": ( - f"{term['clicks']} clicks and ${term['cost_usd']:.2f} with no conversion; " + f"{term['clicks']} clicks and {_show(term['cost'], code)} with no conversion; " + ( "the searcher wanted something else." if label == "irrelevant" @@ -552,7 +566,7 @@ def _pause_ads(reads: dict) -> list[dict]: ] -def _pause_keywords(reads: dict, allowable: float | None, days_live: int) -> list[dict]: +def _pause_keywords(reads: dict, allowable: float | None, days_live: int, code: str) -> list[dict]: if allowable is None or days_live < POLICY["keyword_pause_min_days"]: return [] threshold = allowable * POLICY["keyword_pause_multiple"] @@ -560,18 +574,20 @@ def _pause_keywords(reads: dict, allowable: float | None, days_live: int) -> lis { "resource_name": kw["resource_name"], "text": kw["text"], - "cost_usd": kw["cost_usd"], + "cost": kw["cost"], "why": ( - f"${kw['cost_usd']:.2f} spent over thirty days with no conversion, more than " - f"{POLICY['keyword_pause_multiple']:.0f} times the ${allowable:.2f} you can pay for one." + f"{_show(kw['cost'], code)} spent over thirty days with no conversion, more than " + f"{POLICY['keyword_pause_multiple']:.0f} times the {_show(allowable, code)} you can pay for one." ), } for kw in reads["keywords"] - if kw["status"] == "ENABLED" and kw["conversions"] == 0 and kw["cost_usd"] >= threshold + if kw["status"] == "ENABLED" and kw["conversions"] == 0 and kw["cost"] >= threshold ] -def _bid_proposal(reads: dict, allowable: float | None) -> dict | None: +def _bid_proposal(reads: dict, allowable: float | None, fx: dict) -> dict | None: + code = fx["currency_code"] + money = {"currency_code": code, "usd_rate": fx["usd_rate"]} campaign, month = reads["campaign"], reads["windows"]["30d"] strategy, conversions = campaign["bidding_strategy_type"], month["conversions"] if strategy == "TARGET_SPEND" and conversions >= POLICY["max_conv_threshold"]: @@ -579,9 +595,10 @@ def _bid_proposal(reads: dict, allowable: float | None) -> dict | None: "kind": "bid_strategy_change", "previous": { "strategy": "maximize_clicks", - "cpc_ceiling_usd": campaign["cpc_ceiling_usd"], + "cpc_ceiling": campaign["cpc_ceiling"], + **money, }, - "proposed": {"strategy": "maximize_conversions", "target_cpa_usd": None}, + "proposed": {"strategy": "maximize_conversions", "target_cpa": None, **money}, "rationale": ( f"{conversions:g} conversions in thirty days is enough for Google to bid for " "conversions instead of clicks. Nothing else changes; the daily budget still caps spend." @@ -589,22 +606,22 @@ def _bid_proposal(reads: dict, allowable: float | None) -> dict | None: } if ( strategy == "MAXIMIZE_CONVERSIONS" - and campaign["target_cpa_usd"] is None + and campaign["target_cpa"] is None and conversions >= POLICY["tcpa_threshold"] - and month["cpa_usd"] is not None + and month["cpa"] is not None ): - target = month["cpa_usd"] * POLICY["tcpa_multiplier"] + target = month["cpa"] * POLICY["tcpa_multiplier"] if allowable is not None: target = min(target, allowable) return { "kind": "bid_strategy_change", - "previous": {"strategy": "maximize_conversions", "target_cpa_usd": None}, - "proposed": {"strategy": "target_cpa", "target_cpa_usd": _money(target)}, + "previous": {"strategy": "maximize_conversions", "target_cpa": None, **money}, + "proposed": {"strategy": "target_cpa", "target_cpa": _money(target, code), **money}, "rationale": ( - f"{conversions:g} conversions in thirty days at ${month['cpa_usd']:.2f} each. " - f"A target of ${_money(target):.2f} tells Google what a conversion may cost" + f"{conversions:g} conversions in thirty days at {_show(month['cpa'], code)} each. " + f"A target of {_show(_money(target, code), code)} tells Google what a conversion may cost" + ( - f", never above the ${allowable:.2f} you can pay for one." + f", never above the {_show(allowable, code)} you can pay for one." if allowable is not None else "." ) @@ -613,41 +630,45 @@ def _bid_proposal(reads: dict, allowable: float | None) -> dict | None: return None -def _budget_proposal(reads: dict, allowable: float | None) -> dict | None: +def _budget_proposal(reads: dict, allowable: float | None, fx: dict) -> dict | None: + code = fx["currency_code"] + money = {"currency_code": code, "usd_rate": fx["usd_rate"]} campaign, fortnight = reads["campaign"], reads["windows"]["14d"] - budget = campaign["budget_usd"] + budget = campaign["budget"] if allowable is None or budget <= 0: return None step = POLICY["budget_step"] if ( "BUDGET_CONSTRAINED" in campaign["primary_status_reasons"] - and fortnight["cpa_usd"] is not None - and fortnight["cpa_usd"] <= allowable + and fortnight["cpa"] is not None + and fortnight["cpa"] <= allowable ): - proposed = _money(budget * (1 + step)) + proposed = _money(budget * (1 + step), code) return { "kind": "budget_change", - "previous": {"daily_budget_usd": budget}, - "proposed": {"daily_budget_usd": proposed}, + "previous": {"daily_budget": budget, **money}, + "proposed": {"daily_budget": proposed, **money}, "rationale": ( - f"The budget ran out on most days while a conversion cost ${fortnight['cpa_usd']:.2f}, " - f"under the ${allowable:.2f} you can pay. A {step:.0%} step from ${budget:.2f} to " - f"${proposed:.2f} a day buys more of the same." + f"The budget ran out on most days while a conversion cost {_show(fortnight['cpa'], code)}, " + f"under the {_show(allowable, code)} you can pay. A {step:.0%} step from {_show(budget, code)} to " + f"{_show(proposed, code)} a day buys more of the same." ), } if ( fortnight["conversions"] == 0 - and fortnight["cost_usd"] >= allowable * POLICY["keyword_pause_multiple"] + and fortnight["cost"] >= allowable * POLICY["keyword_pause_multiple"] ): - proposed = max(1.0, _money(budget * (1 - step))) + # Never below a dollar's worth a day, the floor the launch set. + floor = ads_currency.convert(1, fx, floor_usd=1) + proposed = max(floor, _money(budget * (1 - step), code)) return { "kind": "budget_change", - "previous": {"daily_budget_usd": budget}, - "proposed": {"daily_budget_usd": proposed}, + "previous": {"daily_budget": budget, **money}, + "proposed": {"daily_budget": proposed, **money}, "rationale": ( - f"${fortnight['cost_usd']:.2f} in two weeks brought no conversion, more than " - f"{POLICY['keyword_pause_multiple']:.0f} times the ${allowable:.2f} you can pay for one. " - f"A {step:.0%} step down from ${budget:.2f} to ${proposed:.2f} a day slows the loss while " + f"{_show(fortnight['cost'], code)} in two weeks brought no conversion, more than " + f"{POLICY['keyword_pause_multiple']:.0f} times the {_show(allowable, code)} you can pay for one. " + f"A {step:.0%} step down from {_show(budget, code)} to {_show(proposed, code)} a day slows the loss while " "the negatives and keyword pauses take effect." ), } @@ -751,23 +772,43 @@ def decide( """ enabled_on = _iso_date(campaign.get("enabled_at")) days_live = max(0, (today - enabled_on).days) if enabled_on else 0 - allowable = _allowable(campaign) + fx = _fx(campaign) + planned, billed = fx["currency_code"], reads.get("currency_code") + code = billed or planned + # Costs come back in the account's currency. If that is not the currency the launch planned + # in, the plan's figures cannot be compared with them: report plain costs and change nothing + # that depends on cost until the campaign is launched again in the right currency. + mismatch = billed is not None and billed != planned + allowable = None if mismatch else _allowable(campaign) quiet = enabled_on is None or days_live < POLICY["quiet_days"] cap = max(0, min(50, int(auto_negatives_cap))) alerts, findings = _alerts_and_findings(reads, days_live, labels) + if mismatch: + alerts.append( + { + "code": "currency_mismatch", + "text": ( + f"The account bills in {billed}, but this campaign was planned in {planned}: " + f"its budget and bids were sent as {planned} figures and Google read them as " + f"{billed}. Tin reports plain costs and proposes no budget, bidding or keyword " + "changes for it. Check the budget in Google Ads, or launch it again so Tin " + f"plans it in {billed}." + ), + } + ) week = reads["windows"]["7d"] if ( allowable is not None - and week["cpa_usd"] is not None + and week["cpa"] is not None and week["ctr"] >= POLICY["landing_page_ctr"] - and week["cpa_usd"] >= allowable * POLICY["landing_page_cpa_multiple"] + and week["cpa"] >= allowable * POLICY["landing_page_cpa_multiple"] ): findings.append( { "code": "landing_page", "text": ( - f"Click-through is {week['ctr']:.1%} but a conversion costs ${week['cpa_usd']:.2f}, " - f"well above the ${allowable:.2f} you can pay: the ad works and the page does not. " + f"Click-through is {week['ctr']:.1%} but a conversion costs {_show(week['cpa'], code)}, " + f"well above the {_show(allowable, code)} you can pay: the ad works and the page does not. " "The landing page, not the campaign, is the thing to fix." ), } @@ -783,13 +824,16 @@ def decide( auto, proposals = {"negatives": [], "pause_ads": [], "pause_keywords": []}, [] else: auto = { - "negatives": _negatives(reads, labels, cap), + "negatives": _negatives(reads, labels, cap, code), "pause_ads": _pause_ads(reads), - "pause_keywords": _pause_keywords(reads, allowable, days_live), + "pause_keywords": _pause_keywords(reads, allowable, days_live, code), } - proposal = _bid_proposal(reads, allowable) or _budget_proposal(reads, allowable) + proposal = None + if not mismatch: + proposal = _bid_proposal(reads, allowable, fx) or _budget_proposal(reads, allowable, fx) proposals = [proposal] if proposal else [] return { + "currency_code": code, "quiet": quiet, "days_live": days_live, "auto": auto, @@ -797,7 +841,7 @@ def decide( "alerts": alerts, "findings": findings, "metrics": { - "allowable_cpa_usd": allowable, + "allowable_cpa": allowable, "windows": reads["windows"], "campaign": reads["campaign"], "search_terms": len(reads["search_terms"]), @@ -819,16 +863,16 @@ def _plural(count, word) -> str: return f"{count} {word}{'' if count == 1 else 's'}" -def _window_line(name: str, window: dict) -> str: +def _window_line(name: str, window: dict, code: str) -> str: cpa = ( - f"${window['cpa_usd']:.2f} per conversion" - if window["cpa_usd"] is not None + f"{_show(window['cpa'], code)} per conversion" + if window["cpa"] is not None else "no conversion yet" ) return ( - f"- {name}: {_plural(window['clicks'], 'click')} for ${window['cost_usd']:.2f} " + f"- {name}: {_plural(window['clicks'], 'click')} for {_show(window['cost'], code)} " f"({window['ctr']:.1%} of {window['impressions']} impressions clicked, " - f"${window['cpc_usd']:.2f} a click), {window['conversions']:g} conversions, {cpa}." + f"{_show(window['cpc'], code)} a click), {window['conversions']:g} conversions, {cpa}." ) @@ -852,9 +896,10 @@ def _applied_lines(applied: dict) -> list[str]: def proposal_document(proposal: dict, campaign: dict, number: int, today: date) -> str: kind = proposal["kind"] previous, proposed = proposal["previous"], proposal["proposed"] + code = proposed.get("currency_code") or "USD" if kind == "budget_change": title = "Change the daily budget" - change = f"From ${previous['daily_budget_usd']:.2f} a day to ${proposed['daily_budget_usd']:.2f} a day." + change = f"From {_show(previous['daily_budget'], code)} a day to {_show(proposed['daily_budget'], code)} a day." else: title = "Change how Google bids" names = { @@ -863,8 +908,8 @@ def proposal_document(proposal: dict, campaign: dict, number: int, today: date) "target_cpa": "conversions at a target cost each", } change = f"From {names.get(previous.get('strategy'), previous.get('strategy'))} to {names.get(proposed.get('strategy'), proposed.get('strategy'))}" - if proposed.get("target_cpa_usd") is not None: - change += f", target ${proposed['target_cpa_usd']:.2f} per conversion" + if proposed.get("target_cpa") is not None: + change += f", target {_show(proposed['target_cpa'], code)} per conversion" change += "." return ( f"# Proposal {number}: {title}\n\n" @@ -895,7 +940,8 @@ def render( campaign.get("name") or campaign.get("campaign_name") or "your Google Search campaign" ) windows = reads_normalized["windows"] - allowable = decision["metrics"].get("allowable_cpa_usd") + allowable = decision["metrics"].get("allowable_cpa") + code = decision.get("currency_code") or "USD" lines = [ f"# Google Ads check, {today.isoformat()}", "", @@ -905,12 +951,12 @@ def render( "", "## The numbers", "", - _window_line("Last 7 days", windows["7d"]), - _window_line("Last 30 days", windows["30d"]), + _window_line("Last 7 days", windows["7d"], code), + _window_line("Last 30 days", windows["30d"], code), ] if allowable is not None: lines.append( - f"- The most you can pay for one conversion, from the assessment: ${allowable:.2f}." + f"- The most you can pay for one conversion, from the assessment: {_show(allowable, code)}." ) campaign_facts = reads_normalized["campaign"] lines.append( @@ -920,7 +966,7 @@ def render( if campaign_facts["primary_status_reasons"] else "" ) - + f"; daily budget ${campaign_facts['budget_usd']:.2f}." + + f"; daily budget {_show(campaign_facts['budget'], code)}." ) lines += ["", "## What Tin did"] applied_lines = _applied_lines(applied) diff --git a/src/tin_lite/paid_ads_monitor_activities.py b/src/tin_lite/paid_ads_monitor_activities.py index 06a00800..5538822e 100644 --- a/src/tin_lite/paid_ads_monitor_activities.py +++ b/src/tin_lite/paid_ads_monitor_activities.py @@ -42,7 +42,9 @@ # The provider's own wait for one model step, just inside the step's 240-second budget. Without # it the client stops at its 90-second default and a slow but valid answer is lost. MODEL_TIMEOUT_SECONDS = 225 +# The account read only supplies its currency; a missing answer falls back to the launch's. READS = ( + ("account", "account", None), ("campaign_7d", "campaign_health", 7), ("campaign_14d", "campaign_health", 14), ("campaign_30d", "campaign_health", 30), diff --git a/src/tin_lite/paid_ads_proposals.py b/src/tin_lite/paid_ads_proposals.py index a4115863..ba61979a 100644 --- a/src/tin_lite/paid_ads_proposals.py +++ b/src/tin_lite/paid_ads_proposals.py @@ -9,6 +9,7 @@ from uuid import UUID +from tin_lite import ads_currency from tin_lite.google_ads_requests import bidding_body, budget_body from tin_lite.integrations import ( GoogleAdsCallError, @@ -22,11 +23,19 @@ def _bodies(proposal: dict, campaign: dict) -> tuple[str, dict]: proposed = proposal["proposed"] customer = campaign["customer_id"] + # Proposals saved before amounts carried their currency were in dollars. + fx = ( + {**ads_currency.USD_FX, **{k: proposed[k] for k in ("currency_code", "usd_rate")}} + if proposed.get("currency_code") + else ads_currency.USD_FX + ) if proposal["kind"] == "budget_change": budget = f"customers/{customer}/campaignBudgets/{campaign['external_budget_id']}" - return budget_body(budget, proposed["daily_budget_usd"]) + daily = proposed.get("daily_budget", proposed.get("daily_budget_usd")) + return budget_body(budget, daily, fx) campaign_resource = f"customers/{customer}/campaigns/{campaign['external_campaign_id']}" - return bidding_body(campaign_resource, proposed["strategy"], proposed.get("target_cpa_usd")) + target = proposed.get("target_cpa", proposed.get("target_cpa_usd")) + return bidding_body(campaign_resource, proposed["strategy"], target, fx) async def _authorized(runtime, proposal_id: UUID, clerk_user_id: str) -> dict: diff --git a/tests/test_google_ads.py b/tests/test_google_ads.py index 4f4b1010..127a7cbd 100644 --- a/tests/test_google_ads.py +++ b/tests/test_google_ads.py @@ -31,8 +31,8 @@ def plan(**overrides): value = { "marker": requests.marker(RUN_ID), "campaign_name": "Tin | Search | Core", - "daily_budget_usd": 20, - "cpc_ceiling_usd": 2.5, + "daily_budget": 20, + "cpc_ceiling": 2.5, "markets": ["US", "GB"], "landing_page": "https://example.com/pricing", "start_date": "2026-09-23", @@ -255,8 +255,8 @@ def test_temporary_ids_are_negative_unique_and_defined_before_use(): {"markets": ["XX"]}, {"markets": ["US", "US"]}, {"landing_page": "http://example.com"}, - {"daily_budget_usd": 0.5}, - {"cpc_ceiling_usd": 0}, + {"daily_budget": 0.5}, + {"cpc_ceiling": 0}, {"start_date": "23/09/2026"}, {"tracking_template": "https://x?{lpurl}"}, {"sitelinks": [{"text": "x" * 26, "url": "https://e.com"}]}, diff --git a/tests/test_paid_ads_currency.py b/tests/test_paid_ads_currency.py new file mode 100644 index 00000000..3a910ee0 --- /dev/null +++ b/tests/test_paid_ads_currency.py @@ -0,0 +1,401 @@ +"""Google Ads money in the account's own currency: one ECB rate pinned at launch, amounts on +Google's minimum unit, a named refusal for currencies Tin cannot price, and a monitor that +compares costs only against figures in the currency they were planned in.""" + +from __future__ import annotations + +import json +from datetime import date + +import pytest +from temporalio.exceptions import ApplicationError +from test_google_ads import CID +from test_google_ads import plan as bundle_plan +from test_paid_ads_launch import MARKER, TODAY, assessment, inputs, keywords_csv, site +from test_paid_ads_launch import account as launch_account +from test_paid_ads_launch_activities import Script, mutates, run_through_approval +from test_paid_ads_launch_activities import fixture as launch_fixture +from test_paid_ads_monitor import CAMPAIGN, campaign_row, reads + +from tin_lite import ads_currency, paid_ads_launch, paid_ads_monitor +from tin_lite import google_ads_requests as requests +from tin_lite.paid_ads_proposals import _bodies + +FEED = """ + + + + + + + + + + +""" +FEED_DAY = ads_currency.parse_ecb(FEED) +EUR = ads_currency.fx_for("EUR", FEED_DAY) +JPY = ads_currency.fx_for("JPY", FEED_DAY) +IDR = ads_currency.fx_for("IDR", FEED_DAY) + + +# ---------------------------------------------------------------- rates and rounding + + +def test_cross_rates_come_from_one_feed_and_unknown_currencies_are_named(): + assert EUR == { + "currency_code": "EUR", + "usd_rate": "0.800000", + "source": ads_currency.ECB_SOURCE, + "date": "2026-10-12", + } + assert JPY["usd_rate"] == "160.000000" + assert ads_currency.fx_for("TRY", FEED_DAY)["usd_rate"] == "40.000000" + assert ads_currency.fx_for("USD", FEED_DAY) == ads_currency.USD_FX + # ISK has an ECB rate but Google Ads does not bill in it; GBP is not in this feed. + for code in ("ISK", "GBP", "XYZ"): + with pytest.raises(ads_currency.RateUnavailable) as caught: + ads_currency.fx_for(code, FEED_DAY) + assert caught.value.code == "currency_not_published" + for broken in ("", "", FEED.replace("'1.25'", "'0'")): + with pytest.raises(ads_currency.RateUnavailable) as caught: + ads_currency.parse_ecb(broken) + assert caught.value.code == "ecb_feed_unreadable" + + +def test_conversion_rounds_to_googles_unit_and_respects_dollar_floors(): + assert ads_currency.convert(10, EUR) == 8.0 + assert ads_currency.convert(1.234, EUR) == 0.99 + assert ads_currency.convert(10.004, JPY) == 1601.0 # yen has no fraction + assert ads_currency.convert(0.01, JPY, floor_usd=0.05) == 8.0 # 0.05 × 160 rounded up + assert ads_currency.convert(0.001, EUR, floor_usd=0) == 0.01 # never below one unit + assert ads_currency.convert(5, None) == 5.0 + assert ads_currency.money(8, "EUR") == "€8.00" + assert ads_currency.money(1600, "JPY") == "¥1,600" + assert ads_currency.money(650, "TRY", cents=True) == "TRY 650.00" + assert ads_currency.money(5, None) == "5.00 (account currency)" + assert ads_currency.with_usd(8, EUR) == "€8.00 (about $10.00)" + assert ads_currency.with_usd(8, None) == "$8.00" + assert "0.800000 EUR per dollar" in ads_currency.rate_line(EUR) + + +# ---------------------------------------------------------------- request builders + + +def test_bundle_sends_account_currency_micros_on_its_unit(): + ops = requests.campaign_bundle( + bundle_plan(daily_budget=1600, cpc_ceiling=160, fx=JPY), customer_id=CID + ) + budget = ops[0]["campaignBudgetOperation"]["create"]["amountMicros"] + ceiling = ops[1]["campaignOperation"]["create"]["targetSpend"]["cpcBidCeilingMicros"] + assert (budget, ceiling) == ("1600000000", "160000000") + # Ten dollars a day in rupiah is above the old one-million cap; it is still a legal plan. + ops = requests.campaign_bundle( + bundle_plan(daily_budget=160_000, cpc_ceiling=16_000, fx=IDR), customer_id=CID + ) + assert ops[0]["campaignBudgetOperation"]["create"]["amountMicros"] == "160000000000" + + +@pytest.mark.parametrize( + ("override", "message"), + [ + ({"daily_budget": 1600.5, "fx": JPY}, "whole multiple of ¥1"), + ({"daily_budget": 100, "fx": JPY}, "at least ¥160"), + ({"daily_budget": 0.5, "fx": EUR}, "at least €0.80"), + ({"daily_budget": 2_000_000, "fx": EUR}, "below their ceiling"), + ({"fx": {"currency_code": "ISK", "usd_rate": "120"}}, "supported account currency"), + ({"fx": {**EUR, "usd_rate": "0"}}, "must be positive"), + ], +) +def test_bundle_refuses_amounts_off_unit_below_floor_or_in_unknown_currencies(override, message): + with pytest.raises(ValueError, match=message): + requests.campaign_bundle(bundle_plan(**override), customer_id=CID) + + +def test_proposal_bodies_use_the_currency_pinned_on_the_proposal(): + campaign = {"customer_id": CID, "external_budget_id": "11", "external_campaign_id": "22"} + _, body = _bodies( + { + "kind": "budget_change", + "proposed": {"daily_budget": 1600.0, "currency_code": "JPY", "usd_rate": "160"}, + }, + campaign, + ) + assert body["operations"][0]["update"]["amountMicros"] == "1600000000" + _, body = _bodies( + { + "kind": "bid_strategy_change", + "proposed": { + "strategy": "target_cpa", + "target_cpa": 24.0, + "currency_code": "EUR", + "usd_rate": "0.8", + }, + }, + campaign, + ) + assert body["operations"][0]["update"]["maximizeConversions"] == {"targetCpaMicros": "24000000"} + with pytest.raises(ValueError, match="whole multiple"): + _bodies( + { + "kind": "budget_change", + "proposed": {"daily_budget": 1600.5, "currency_code": "JPY", "usd_rate": "160"}, + }, + campaign, + ) + + +# ---------------------------------------------------------------- launch plan + + +def test_plan_converts_budget_bids_and_targets_once_at_the_pinned_rate(): + plan = paid_ads_launch.plan_skeleton( + assessment=assessment(), + keywords_csv=keywords_csv(), + inputs=inputs(), + marker=MARKER, + today=TODAY, + fx=JPY, + ) + assert plan["currency_code"] == "JPY" and plan["fx"] == JPY + # The same plan in dollars is $10 a day, a $1.00 ceiling and $30 per customer. + assert (plan["daily_budget"], plan["cpc_ceiling"], plan["allowable_cpa"]) == ( + 1600.0, + 160.0, + 4800.0, + ) + + +def test_gate_names_an_account_currency_tin_cannot_price(): + for currency, words in (("ISK", "bills in ISK"), (None, "did not report")): + acct = launch_account() + acct["customer"]["currency_code"] = currency + gate = paid_ads_launch.gate( + assessment=assessment(), + account=acct, + site=site(), + inputs=inputs(), + existing_campaign=None, + ) + assert gate["outcome"] == "unsupported_currency" in paid_ads_launch.OUTCOMES + assert words in gate["text"] and "Nothing will be created" in gate["text"] + + +def test_a_plan_drafted_before_currencies_reads_as_dollars(): + legacy = { + "campaign_name": "x", + "daily_budget_usd": 10.0, + "cpc_ceiling_usd": 1.0, + "allowable_cpa_usd": 30.0, + "target_cpa_usd": None, + "currency_code": "USD", + } + upgraded = paid_ads_launch.upgrade_plan(legacy) + assert upgraded["fx"] == ads_currency.USD_FX and upgraded["daily_budget"] == 10.0 + assert upgraded["allowable_cpa"] == 30.0 and "daily_budget_usd" not in upgraded + assert paid_ads_launch.upgrade_plan(upgraded) is upgraded + + +# ---------------------------------------------------------------- launch activities + + +def rates(feed=FEED): + calls = [] + + async def read(): + calls.append(1) + return feed + + read.calls = calls + return read + + +@pytest.mark.asyncio +async def test_a_euro_account_is_planned_approved_and_created_in_euros(): + script, reader = Script(), rates() + script.currency = "EUR" + activities, db, storage, script, _, _, _ = await launch_fixture( + script=script, rate_reader=reader + ) + run_id, mode = await run_through_approval(activities, db) + assert mode == "launch" and len(reader.calls) == 1 + assert (await activities._result(run_id, "gather:fx"))["value"] == EUR + assert db.campaigns[db.run.id]["daily_budget_micros"] == 8_000_000 + assert "€8.00 (about $10.00) a day" in db.reviews[0]["summary"] + names = paid_ads_launch.paths(run_id, paid_ads_launch.PLAN_DOCS) + tree = storage.repo.trees[storage.repo.head] + plan_md = tree[names["PLAN.md"]][1].decode() + assert "€8.00 (about $10.00) on an average day" in plan_md + assert "0.800000 EUR per dollar" in plan_md + + await activities.apply(run_id) + (create,) = mutates(script, validate_only=False) + operations = create[1]["operations"] + assert operations[0]["campaignBudgetOperation"]["create"]["amountMicros"] == "8000000" + ceiling = operations[1]["campaignOperation"]["create"]["targetSpend"]["cpcBidCeilingMicros"] + assert ceiling == "800000" + + await activities.publish(run_id) + results = paid_ads_launch.paths(run_id, paid_ads_launch.RESULT_DOCS) + tree = storage.repo.trees[storage.repo.head] + saved = json.loads(tree[results["campaign.json"]][1]) + assert saved["currency_code"] == "EUR" and saved["fx"] == EUR + assert saved["daily_budget"] == 8.0 and saved["allowable_cpa"] == 24.0 + assert len(reader.calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("currency", ["ISK", None]) +async def test_an_unpriceable_currency_stops_before_any_rate_model_or_write(currency): + script, reader = Script(), rates() + script.currency = currency + activities, db, storage, script, _, _, model = await launch_fixture( + script=script, rate_reader=reader + ) + run_id = str(db.run.id) + await activities.prepare(run_id) + await activities.gather(run_id) + assert await activities.draft(run_id) == "setup" + gate = (await activities._result(run_id, "gathered"))["gate"] + assert gate["outcome"] == "unsupported_currency" + assert reader.calls == [] and model.calls == [] + assert all(c[0] == "search" for c in script.calls) + names = paid_ads_launch.paths(run_id, paid_ads_launch.SETUP_DOCS) + assert names["SETUP.md"] in storage.repo.trees[storage.repo.head] + + +@pytest.mark.asyncio +async def test_an_unreadable_rate_retries_and_changes_nothing(): + script = Script() + script.currency = "EUR" + + async def down(): + raise ads_currency.RateUnavailable("ecb_unreachable") + + activities, db, _, script, _, _, model = await launch_fixture(script=script, rate_reader=down) + run_id = str(db.run.id) + await activities.prepare(run_id) + with pytest.raises(ApplicationError, match="ecb_unreachable") as caught: + await activities.gather(run_id) + assert caught.value.non_retryable is False + assert await activities._result(run_id, "gathered") is None and model.calls == [] + activities.read_rates = rates() + await activities.gather(run_id) + assert (await activities._result(run_id, "gathered"))["skeleton"]["fx"] == EUR + + +@pytest.mark.asyncio +async def test_apply_refuses_a_plan_in_another_currency_than_the_account(): + activities, db, _, script, _, _, _ = await launch_fixture() + run_id, _ = await run_through_approval(activities, db) + db.effects[activities.key(run_id, "gathered:account")].result["value"]["customer"][ + "currency_code" + ] = "EUR" + with pytest.raises(ApplicationError, match="in USD but the account bills in EUR"): + await activities.apply(run_id) + assert mutates(script, validate_only=True) == [] and mutates(script, validate_only=False) == [] + + +@pytest.mark.asyncio +async def test_an_approval_waiting_from_before_currencies_still_applies_in_dollars(): + activities, db, _, script, _, _, _ = await launch_fixture() + run_id, _ = await run_through_approval(activities, db) + draft = db.effects[activities.key(run_id, "draft")].result + plan = draft["plan"] + for key in ("daily_budget", "cpc_ceiling", "allowable_cpa", "target_cpa"): + plan[f"{key}_usd"] = plan.pop(key) + del plan["fx"] + await activities.apply(run_id) + (create,) = mutates(script, validate_only=False) + budget = create[1]["operations"][0]["campaignBudgetOperation"]["create"]["amountMicros"] + assert budget == "10000000" + + +# ---------------------------------------------------------------- monitor + + +EURO_CAMPAIGN = { + "name": "Tin | Search | Core [tin:abc]", + "enabled_at": "2026-09-01T10:00:00+00:00", + "currency_code": "EUR", + "fx": EUR, + "allowable_cpa": 30.0, + "daily_budget": 20.0, + "cpc_ceiling": 2.0, +} +ACCOUNT = lambda code: [{"customer": {"id": "1", "currencyCode": code}}] # noqa: E731 +CONSTRAINED = { + "campaign_7d": [ + campaign_row( + campaign={"primaryStatus": "LIMITED", "primaryStatusReasons": ["BUDGET_CONSTRAINED"]} + ) + ], + "campaign_30d": [campaign_row(metrics={"conversions": 5.0})], +} + + +def monitor_decide(campaign, reads_value): + return paid_ads_monitor.decide( + campaign=campaign, + reads=reads_value, + labels={}, + today=date(2026, 10, 12), + auto_negatives_cap=20, + ) + + +def test_monitor_proposes_and_reports_in_the_currency_the_campaign_was_planned_in(): + decision = monitor_decide(EURO_CAMPAIGN, reads(account=ACCOUNT("EUR"), **CONSTRAINED)) + assert decision["currency_code"] == "EUR" + (proposal,) = decision["proposals"] + assert proposal["proposed"] == { + "daily_budget": 24.0, + "currency_code": "EUR", + "usd_rate": "0.800000", + } + assert "under the €30.00 you can pay" in proposal["rationale"] + document = paid_ads_monitor.proposal_document(proposal, EURO_CAMPAIGN, 1, date(2026, 10, 12)) + assert "From €20.00 a day to €24.00 a day." in document + # A lost account read falls back to the launch's currency instead of failing the check. + assert monitor_decide(EURO_CAMPAIGN, reads(**CONSTRAINED))["currency_code"] == "EUR" + + +def test_monitor_steps_yen_budgets_in_whole_yen_and_never_below_a_dollars_worth(): + yen = {**EURO_CAMPAIGN, "currency_code": "JPY", "fx": JPY, "allowable_cpa": 30.0} + wasteful = reads( + account=ACCOUNT("JPY"), + campaign_7d=[campaign_row(budget={"amountMicros": "2000000000"})], + campaign_14d=[campaign_row(metrics={"costMicros": "95000000", "conversions": 0.0})], + campaign_30d=[campaign_row(metrics={"conversions": 5.0})], + ) + (proposal,) = monitor_decide(yen, wasteful)["proposals"] + assert proposal["proposed"]["daily_budget"] == 1600.0 + assert "from ¥2,000 to ¥1,600 a day" in proposal["rationale"] + tiny = reads( + account=ACCOUNT("JPY"), + campaign_7d=[campaign_row(budget={"amountMicros": "170000000"})], + campaign_14d=[campaign_row(metrics={"costMicros": "95000000", "conversions": 0.0})], + campaign_30d=[campaign_row(metrics={"conversions": 5.0})], + ) + (proposal,) = monitor_decide(yen, tiny)["proposals"] + assert proposal["proposed"]["daily_budget"] == 160.0 + + +def test_a_dollar_plan_in_a_non_dollar_account_reports_plainly_and_changes_no_spend(): + decision = monitor_decide(CAMPAIGN, reads(account=ACCOUNT("TRY"), **CONSTRAINED)) + assert decision["currency_code"] == "TRY" and decision["proposals"] == [] + assert decision["auto"]["pause_keywords"] == [] + assert decision["metrics"]["allowable_cpa"] is None + (alert,) = [a for a in decision["alerts"] if a["code"] == "currency_mismatch"] + assert "bills in TRY" in alert["text"] and "planned in USD" in alert["text"] + report = paid_ads_monitor.render( + decision=decision, + campaign=CAMPAIGN, + reads_normalized=reads(account=ACCOUNT("TRY"), **CONSTRAINED), + brief={"summary": "", "changes_explained": [], "watch_for": []}, + applied={}, + proposals_saved=[], + run_id="00000000-0000-4000-8000-00000000bbbb", + today=date(2026, 10, 12), + )["MONITOR.md"] + assert "daily budget TRY 20.00" in report and "$" not in report diff --git a/tests/test_paid_ads_launch.py b/tests/test_paid_ads_launch.py index 77a8c2a0..baae7542 100644 --- a/tests/test_paid_ads_launch.py +++ b/tests/test_paid_ads_launch.py @@ -83,7 +83,6 @@ def skeleton(**overrides): assessment=assessment(), keywords_csv=keywords_csv(), inputs=inputs(**overrides), - account=account(), marker=MARKER, today=TODAY, ) @@ -222,9 +221,9 @@ def test_conversion_category_maps_events_and_names_actions(): def test_plan_skeleton_joins_keywords_and_computes_the_numbers(): plan = skeleton() assert plan["campaign_name"].endswith(MARKER) and len(plan["campaign_name"]) <= 80 - assert plan["daily_budget_usd"] == 10 and plan["markets"] == ["US", "GB"] + assert plan["daily_budget"] == 10 and plan["markets"] == ["US", "GB"] # ceiling = min(0.1 × budget, 1.2 × median high bid of the chosen keywords) - assert plan["cpc_ceiling_usd"] == 1.0 + assert plan["cpc_ceiling"] == 1.0 assert plan["landing_page"] == "https://www.clawmessenger.com/pricing" assert plan["start_date"] == "2026-09-23" and plan["currency_code"] == "USD" assert [g["name"] for g in plan["ad_groups"]] == [ @@ -242,7 +241,7 @@ def test_plan_skeleton_joins_keywords_and_computes_the_numbers(): assert len(texts) == len(set(texts)) and "imessage api" not in texts assert all(n["match_type"] in {"PHRASE", "EXACT"} for n in plan["negatives"]) assert plan["competitors"] == ["sendblue"] - assert plan["allowable_cpa_usd"] == 30.0 and plan["assessment_run_id"] == RUN_ID + assert plan["allowable_cpa"] == 30.0 and plan["assessment_run_id"] == RUN_ID def test_plan_skeleton_honours_founder_overrides_and_refuses_broad(): @@ -252,7 +251,7 @@ def test_plan_skeleton_honours_founder_overrides_and_refuses_broad(): landing_page_url="https://www.clawmessenger.com/", campaign_name="Claw search test", ) - assert plan["daily_budget_usd"] == 40 and plan["cpc_ceiling_usd"] == 2.5 + assert plan["daily_budget"] == 40 and plan["cpc_ceiling"] == 2.5 assert plan["landing_page"] == "https://www.clawmessenger.com/" assert plan["campaign_name"] == f"Claw search test {MARKER}" broad = assessment() @@ -262,7 +261,6 @@ def test_plan_skeleton_honours_founder_overrides_and_refuses_broad(): assessment=broad, keywords_csv=keywords_csv(), inputs=inputs(), - account=account(), marker=MARKER, today=TODAY, ) @@ -275,7 +273,6 @@ def test_plan_skeleton_honours_founder_overrides_and_refuses_broad(): assessment=empty, keywords_csv=keywords_csv(), inputs=inputs(), - account=account(), marker=MARKER, today=TODAY, ) @@ -295,7 +292,6 @@ def test_negatives_never_block_a_keyword_the_campaign_buys(monkeypatch): assessment=data, keywords_csv=rows, inputs=inputs(), - account=account(), marker=MARKER, today=TODAY, ) @@ -435,7 +431,7 @@ def _negatives(self, user, schema): def _brief(self, user, schema): facts = json.loads(user.split("PLAN FACTS (the only numbers you may use):\n", 1)[1]) return { - "summary": f"One search campaign at ${facts['daily_budget_usd']} a day.", + "summary": f"One search campaign at ${facts['daily_budget']} a day.", "what_tin_will_do": ["Create the campaign paused, then turn it on."], "what_tin_will_not_do": ["No broad match.", "No spend before approval."], "watch_for": ["Search terms in the first week."], @@ -495,7 +491,6 @@ def test_prune_copy_drops_rule_breaking_extras_instead_of_failing(): assessment=assessment(), keywords_csv=keywords_csv(), inputs=inputs(), - account={"customer": {}}, marker=MARKER, today=TODAY, ) @@ -610,7 +605,7 @@ def test_renders_stay_readable_and_within_limits(): ) assert "The campaign is on." in result["RESULT.md"] campaign = json.loads(result["campaign.json"]) - assert campaign["campaign_id"] == "555" and campaign["allowable_cpa_usd"] == 30.0 + assert campaign["campaign_id"] == "555" and campaign["allowable_cpa"] == 30.0 assert campaign["launch_run_id"] == RUN_ID and campaign["marker"] == MARKER for name, content in {**tracking, **setup, **result}.items(): limits = {**launch.TRACKING_DOCS, **launch.SETUP_DOCS, **launch.RESULT_DOCS} @@ -626,7 +621,6 @@ def test_plan_skeleton_creates_held_back_groups_paused(): assessment=data, keywords_csv=keywords_csv(), inputs=inputs(), - account={"customer": {}}, marker=MARKER, today=TODAY, ) diff --git a/tests/test_paid_ads_launch_activities.py b/tests/test_paid_ads_launch_activities.py index 0d4c00bd..a3e690c3 100644 --- a/tests/test_paid_ads_launch_activities.py +++ b/tests/test_paid_ads_launch_activities.py @@ -177,6 +177,7 @@ def __init__(self): self.lookup_found = False self.enable_lost = False self.status_after_enable = None + self.currency = "USD" self.sent = [] self.calls = [] @@ -263,7 +264,7 @@ def rows(self, query): "customer": { "id": CUSTOMER, "descriptiveName": "Claw Messenger", - "currencyCode": "USD", + "currencyCode": self.currency, "timeZone": "America/New_York", "status": "ENABLED", "autoTaggingEnabled": True, @@ -362,7 +363,7 @@ def assessment_documents(run_id): } -async def fixture(*, script=None, model=None, github=None, site=None): +async def fixture(*, script=None, model=None, github=None, site=None, rate_reader=None): db, storage = LaunchDB(), HistoryStorage() script = script or Script() documents = assessment_documents(str(db.assessment_run.id)) @@ -418,6 +419,7 @@ async def read_site(url, *, html_scan=None): router=router, integrations=integrations, site_reader=read_site, + rate_reader=rate_reader, ) return activities, db, storage, script, router, integrations, model @@ -486,7 +488,7 @@ async def test_ready_path_validates_then_creates_once_and_a_replay_buys_nothing( assert set(results.values()) <= set(tree) and storage.repo.writes == 2 saved = json.loads(tree[results["campaign.json"]][1]) assert saved["campaign_id"] == "2" and saved["launch_run_id"] == run_id - assert saved["allowable_cpa_usd"] == 30.0 and saved["enabled"] is True + assert saved["allowable_cpa"] == 30.0 and saved["enabled"] is True assert b"The campaign is on" in tree[results["RESULT.md"]][1] assert db.completions[0]["campaign_status"] == "live" assert db.completions[0]["event_type"] == "paid_ads_launch_ready" diff --git a/tests/test_paid_ads_monitor.py b/tests/test_paid_ads_monitor.py index 5e7fdbd6..b768e9b5 100644 --- a/tests/test_paid_ads_monitor.py +++ b/tests/test_paid_ads_monitor.py @@ -211,18 +211,18 @@ def test_normalize_reads_converts_micros_and_tolerates_missing_fields(): picture = reads() campaign = picture["campaign"] assert campaign["id"] == "24143450789" and campaign["bidding_strategy_type"] == "TARGET_SPEND" - assert campaign["cpc_ceiling_usd"] == 2.0 and campaign["target_cpa_usd"] is None - assert campaign["budget_usd"] == 20.0 and campaign["recommended_budget_usd"] == 30.0 + assert campaign["cpc_ceiling"] == 2.0 and campaign["target_cpa"] is None + assert campaign["budget"] == 20.0 and campaign["recommended_budget"] == 30.0 assert campaign["ai_max_enabled"] is False and campaign["migration_dates"] == [] week = picture["windows"]["7d"] assert week == { "impressions": 1000, "clicks": 40, - "cost_usd": 80.0, + "cost": 80.0, "conversions": 4.0, "ctr": 0.04, - "cpc_usd": 2.0, - "cpa_usd": 20.0, + "cpc": 2.0, + "cpa": 20.0, "impression_share": 0.2, "budget_lost_share": 0.1, "rank_lost_share": 0.5, @@ -233,9 +233,7 @@ def test_normalize_reads_converts_micros_and_tolerates_missing_fields(): "imessage api jobs", ] assert picture["search_terms"][0]["conversions"] == 1.0 - assert ( - picture["keywords"][0]["quality_score"] == 6 and picture["keywords"][0]["cost_usd"] == 100.0 - ) + assert picture["keywords"][0]["quality_score"] == 6 and picture["keywords"][0]["cost"] == 100.0 assert picture["ads"][1]["approval_status"] == "DISAPPROVED" and picture["ads"][1][ "topics" ] == ["TRADEMARKS"] @@ -245,7 +243,7 @@ def test_normalize_reads_converts_micros_and_tolerates_missing_fields(): assert picture["subscriptions"] == [{"type": "KEYWORD", "status": "PAUSED"}] empty = monitor.normalize_reads({}) - assert empty["campaign"]["status"] == "UNKNOWN" and empty["windows"]["7d"]["cpa_usd"] is None + assert empty["campaign"]["status"] == "UNKNOWN" and empty["windows"]["7d"]["cpa"] is None assert empty["windows"]["7d"]["impression_share"] is None and empty["search_terms"] == [] junk = monitor.normalize_reads( {"search_terms": ["x", {"metrics": {"clicks": "nan"}}], "ads": [{}]} @@ -381,7 +379,7 @@ def test_negatives_judge_a_term_across_every_row_it_appears_in(): ] ) negatives = decide(split)["auto"]["negatives"] - assert [(n["text"], n["clicks"], n["cost_usd"]) for n in negatives] == [ + assert [(n["text"], n["clicks"], n["cost"]) for n in negatives] == [ ("send imessage from python free", 2, 5.5) ] assert negatives[0]["why"].startswith("2 clicks and $5.50") @@ -415,8 +413,13 @@ def test_bid_strategy_proposals_follow_conversion_thresholds(): assert len(decision["proposals"]) == 1 proposal = decision["proposals"][0] assert proposal["kind"] == "bid_strategy_change" - assert proposal["proposed"] == {"strategy": "maximize_conversions", "target_cpa_usd": None} - assert proposal["previous"]["cpc_ceiling_usd"] == 2.0 + assert proposal["proposed"] == { + "strategy": "maximize_conversions", + "target_cpa": None, + "currency_code": "USD", + "usd_rate": "1", + } + assert proposal["previous"]["cpc_ceiling"] == 2.0 few = reads(campaign_30d=[campaign_row(metrics={"conversions": 14.0})]) assert decide(few)["proposals"] == [] @@ -435,10 +438,15 @@ def test_bid_strategy_proposals_follow_conversion_thresholds(): ], ) proposal = decide(max_conv)["proposals"][0] - assert proposal["proposed"] == {"strategy": "target_cpa", "target_cpa_usd": 22.0} # 20 × 1.1 + assert proposal["proposed"] == { + "strategy": "target_cpa", + "target_cpa": 22.0, + "currency_code": "USD", + "usd_rate": "1", + } # 20 × 1.1 assert "$22.00" in proposal["rationale"] capped = decide(max_conv, allowable_cpa_usd=21.0)["proposals"][0] - assert capped["proposed"]["target_cpa_usd"] == 21.0 + assert capped["proposed"]["target_cpa"] == 21.0 already = reads( campaign_7d=[ @@ -468,8 +476,8 @@ def test_budget_proposals_step_twenty_percent_and_yield_to_bid_changes(): ) proposal = decide(constrained)["proposals"][0] assert proposal["kind"] == "budget_change" - assert proposal["previous"] == {"daily_budget_usd": 20.0} - assert proposal["proposed"] == {"daily_budget_usd": 24.0} + assert proposal["previous"] == {"daily_budget": 20.0, "currency_code": "USD", "usd_rate": "1"} + assert proposal["proposed"] == {"daily_budget": 24.0, "currency_code": "USD", "usd_rate": "1"} assert "under the $30.00" in proposal["rationale"] expensive = reads( @@ -486,13 +494,13 @@ def test_budget_proposals_step_twenty_percent_and_yield_to_bid_changes(): campaign_30d=[campaign_row(metrics={"conversions": 5.0})], ) proposal = decide(wasteful)["proposals"][0] - assert proposal["proposed"] == {"daily_budget_usd": 16.0} + assert proposal["proposed"] == {"daily_budget": 16.0, "currency_code": "USD", "usd_rate": "1"} tiny = reads( campaign_7d=[campaign_row(budget={"amountMicros": "1000000"})], campaign_14d=[campaign_row(metrics={"costMicros": "95000000", "conversions": 0.0})], campaign_30d=[campaign_row(metrics={"conversions": 5.0})], ) - assert decide(tiny)["proposals"][0]["proposed"]["daily_budget_usd"] == 1.0 + assert decide(tiny)["proposals"][0]["proposed"]["daily_budget"] == 1.0 # A due bidding change wins and only one proposal is ever made per check. both = reads( @@ -556,7 +564,7 @@ def test_decision_is_deterministic_and_json_serialisable(): first, second = decide(), decide() assert first == second json.dumps(first) - assert first["metrics"]["allowable_cpa_usd"] == 30.0 and first["metrics"]["search_terms"] == 5 + assert first["metrics"]["allowable_cpa"] == 30.0 and first["metrics"]["search_terms"] == 5 # ---------------------------------------------------------------- rendering @@ -657,16 +665,26 @@ def test_proposal_documents_explain_the_change_and_the_approval(): assert "Nothing changes until you approve" in text budget = { "kind": "budget_change", - "previous": {"daily_budget_usd": 20.0}, - "proposed": {"daily_budget_usd": 24.0}, + "previous": {"daily_budget": 20.0, "currency_code": "USD", "usd_rate": "1"}, + "proposed": {"daily_budget": 24.0, "currency_code": "USD", "usd_rate": "1"}, "rationale": "The budget ran out.", } text = monitor.proposal_document(budget, {"campaign_name": "Core"}, 2, TODAY) assert "From $20.00 a day to $24.00 a day." in text and "Campaign: Core." in text tcpa = { "kind": "bid_strategy_change", - "previous": {"strategy": "maximize_conversions", "target_cpa_usd": None}, - "proposed": {"strategy": "target_cpa", "target_cpa_usd": 22.0}, + "previous": { + "strategy": "maximize_conversions", + "target_cpa": None, + "currency_code": "USD", + "usd_rate": "1", + }, + "proposed": { + "strategy": "target_cpa", + "target_cpa": 22.0, + "currency_code": "USD", + "usd_rate": "1", + }, "rationale": "Enough conversions.", } assert "target $22.00 per conversion" in monitor.proposal_document(tcpa, CAMPAIGN, 3, TODAY) diff --git a/tests/test_paid_ads_monitor_activities.py b/tests/test_paid_ads_monitor_activities.py index f19c04c6..fad5a536 100644 --- a/tests/test_paid_ads_monitor_activities.py +++ b/tests/test_paid_ads_monitor_activities.py @@ -81,6 +81,8 @@ def metrics(*, clicks, cost_usd, conversions, impressions=1000, lost=None): def rows_for(query: str, *, reasons) -> list[dict]: + if "FROM customer" in query: + return [{"customer": {"id": CUSTOMER, "currencyCode": "USD"}}] campaign = { "id": CAMPAIGN_ID, "name": "Tin Search · Claw Messenger [tin:abcdef123456]", @@ -381,7 +383,7 @@ async def test_full_path_applies_bounded_changes_saves_one_proposal_and_publishe run_id = str(db.run.id) await finish(activities, run_id) - assert len(integrations.searches) == 9 + assert len(integrations.searches) == len(READS) for name, _query, _days in READS: receipt = db.effects[activities.key(run_id, f"read:{name}")] assert receipt.status == "completed" and receipt.result["status"] == "completed" @@ -398,7 +400,7 @@ async def test_full_path_applies_bounded_changes_saves_one_proposal_and_publishe decision = db.effects[activities.key(run_id, "decision")].result["decision"] assert not decision["quiet"] and decision["auto"]["pause_keywords"] == [] assert [p["kind"] for p in decision["proposals"]] == ["budget_change"] - assert decision["proposals"][0]["proposed"]["daily_budget_usd"] == 24.0 + assert decision["proposals"][0]["proposed"]["daily_budget"] == 24.0 assert [row["kind"] for row in db.proposals.values()] == ["budget_change"] storage.publish_state_documents.assert_awaited_once() published = storage.publish_state_documents.await_args.kwargs["documents"] @@ -431,7 +433,7 @@ async def test_quiet_period_reads_only_and_says_so(): activities, db, storage, integrations, model = fixture(db=MonitorDB(enabled_days_ago=1)) run_id = str(db.run.id) await finish(activities, run_id) - assert len(integrations.searches) == 9 + assert len(integrations.searches) == len(READS) assert integrations.mutations == [] assert db.proposals == {} storage.publish_state_documents.assert_not_awaited()