From 2f877279d7f32f13a5682c8bb235e9f381061f8e Mon Sep 17 00:00:00 2001 From: Ege Demir Date: Mon, 12 Oct 2026 14:44:50 +0000 Subject: [PATCH] Plan and monitor Google Ads in the account's own currency Google reads every amount in the account's currency, but launch and monitor treated them as dollars. A launch now reads the account's currency, pins one ECB reference rate, converts the plan's dollar figures once onto Google's minimum unit and shows both before approval. The monitor reports and proposes in that currency. Currencies Tin cannot price stop as unsupported_currency. Plans and proposals saved before this read as USD. Co-Authored-By: Claude Opus 5.5 --- docs/paid-ads-launch-implementation.md | 38 +- src/tin_lite/ads_currency.py | 200 ++++++++++ src/tin_lite/catalog.py | 4 +- src/tin_lite/db.py | 5 +- src/tin_lite/google_ads_requests.py | 70 +++- src/tin_lite/paid_ads_launch.py | 133 +++++-- src/tin_lite/paid_ads_launch_activities.py | 123 ++++-- src/tin_lite/paid_ads_monitor.py | 198 ++++++---- src/tin_lite/paid_ads_monitor_activities.py | 2 + src/tin_lite/paid_ads_proposals.py | 13 +- tests/test_google_ads.py | 8 +- tests/test_paid_ads_currency.py | 401 ++++++++++++++++++++ tests/test_paid_ads_launch.py | 18 +- tests/test_paid_ads_launch_activities.py | 8 +- tests/test_paid_ads_monitor.py | 64 ++-- tests/test_paid_ads_monitor_activities.py | 8 +- 16 files changed, 1076 insertions(+), 217 deletions(-) create mode 100644 src/tin_lite/ads_currency.py create mode 100644 tests/test_paid_ads_currency.py 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()