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()