diff --git a/docs/paid-ads-launch-implementation.md b/docs/paid-ads-launch-implementation.md index 4815aa39..48afc9fe 100644 --- a/docs/paid-ads-launch-implementation.md +++ b/docs/paid-ads-launch-implementation.md @@ -29,6 +29,11 @@ manager link from Tin's side. ## Inputs and the gate +Launch and monitoring require the account read to confirm `USD`. Their budgets, +bids and cost comparisons are in dollars; Google Ads interprets amounts in the +account's currency. Tin stops before tracking changes, model calls or campaign +writes when the currency is missing or different. No exchange rate is guessed. + `paid_ads_launch.INPUT_SCHEMA` takes the assessment run (verified against its publish receipt by `paid_ads_sources.pinned_bundle`), an optional daily budget and click ceiling, an optional landing page and campaign name, notes and a model-cost ceiling. `gate()` decides one outcome @@ -43,7 +48,7 @@ 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 + `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. ## The plan and the one approval diff --git a/src/tin_lite/paid_ads_launch.py b/src/tin_lite/paid_ads_launch.py index 3f049c94..a79318e1 100644 --- a/src/tin_lite/paid_ads_launch.py +++ b/src/tin_lite/paid_ads_launch.py @@ -79,6 +79,7 @@ "campaign_exists", "account_disabled", "not_recommended", + "unsupported_currency", ) LAUNCHABLE = {"test", "continue", "go", "restart", "restructure"} LIMITS = { @@ -384,6 +385,16 @@ def gate(*, assessment: dict, account: dict, site: dict, inputs: dict, existing_ ), "conversion_action": None, } + if customer.get("currency_code") != "USD": + return { + "outcome": "unsupported_currency", + "text": ( + "Tin's campaign budgets and bids are in USD, but this account does not " + "confirm USD as its currency. Nothing will be created. Use a USD account; " + "Tin does not convert currencies." + ), + "conversion_action": None, + } if not (account.get("billing") or {}).get("approved"): return { "outcome": "needs_billing", diff --git a/src/tin_lite/paid_ads_monitor_activities.py b/src/tin_lite/paid_ads_monitor_activities.py index 06a00800..38a68a14 100644 --- a/src/tin_lite/paid_ads_monitor_activities.py +++ b/src/tin_lite/paid_ads_monitor_activities.py @@ -43,6 +43,7 @@ # it the client stops at its 90-second default and a slow but valid answer is lost. MODEL_TIMEOUT_SECONDS = 225 READS = ( + ("account", "account", None), ("campaign_7d", "campaign_health", 7), ("campaign_14d", "campaign_health", 14), ("campaign_30d", "campaign_health", 30), @@ -396,6 +397,31 @@ async def read(self, run_id: str) -> None: raw[name] = [] else: raw[name] = _rows(result) + if name == "account": + if name in unavailable: + await self._save( + run_id, + "failure", + { + "code": "account_unavailable", + "reason": result.get("error") or result.get("reason") or "unknown", + "billing_error_code": result.get("billing_error_code"), + }, + ) + raise ApplicationError( + "The Google Ads account read was unavailable " + f"({result.get('error') or result.get('reason') or 'unknown'}). " + "Nothing was changed.", + non_retryable=result.get("error") is not None + or result.get("reason") in {"spending_limit", "billing_refused"}, + ) + account = next(iter(raw[name]), {}) + if (account.get("customer") or {}).get("currencyCode") != "USD": + raise ApplicationError( + "Google Ads did not confirm USD as the account currency. " + "Tin's monitor compares costs in USD; nothing was changed.", + non_retryable=True, + ) if activity.in_activity(): activity.heartbeat({"stage": name}) if "campaign_7d" in unavailable: @@ -674,4 +700,12 @@ async def failure(self, run_id: str) -> None: "The Google Ads check stopped before finishing. Changes already applied stand; " "the next check reconciles anything unconfirmed.", ) + if failure.get("code") == "account_unavailable": + reason = failure.get("billing_error_code") or failure.get("reason") or "unknown" + if reason == "spending_limit": + reason = "spending limit" + message = ( + "The Google Ads check stopped because the account read was unavailable " + f"({reason}). Nothing was changed." + ) await self.db.project_failure(run_id=run.id, error_message=message) diff --git a/tests/test_paid_ads_currency.py b/tests/test_paid_ads_currency.py new file mode 100644 index 00000000..13ae2e87 --- /dev/null +++ b/tests/test_paid_ads_currency.py @@ -0,0 +1,89 @@ +import pytest +from temporalio.exceptions import ApplicationError +from test_paid_ads_launch import account, assessment, inputs, site +from test_paid_ads_launch_activities import fixture as launch_fixture +from test_paid_ads_monitor_activities import fixture as monitor_fixture + +from tin_lite import paid_ads_launch + + +@pytest.mark.parametrize("currency", ["EUR", "GBP", "TRY", None]) +def test_launch_does_not_treat_account_currency_as_dollars(currency): + acct = 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" + assert gate["outcome"] in paid_ads_launch.OUTCOMES + assert "USD" in gate["text"] + + +@pytest.mark.parametrize("currency", ["EUR", None]) +async def test_launch_stops_before_model_or_tracking_write(currency): + activities, db, storage, script, router, integrations, model = await launch_fixture() + original = script.rows + + def rows(query): + result = original(query) + if "FROM customer" in query: + result[0]["customer"]["currencyCode"] = currency + return result + + script.rows = rows + run_id = str(db.run.id) + await activities.prepare(run_id) + await activities.gather(run_id) + assert await activities.draft(run_id) == "setup" + assert not router.generate.await_count + assert all(kind == "search" for kind, _, _ in script.calls) + + +@pytest.mark.parametrize("currency", ["EUR", "GBP", "TRY", None]) +async def test_monitor_stops_before_model_or_mutation(currency): + activities, db, storage, integrations, model = monitor_fixture() + original = integrations.google_ads_call + + async def call(**kwargs): + if kwargs["kind"] == "search" and "FROM customer" in kwargs["request"]["query"]: + return {"rows": [{"customer": {"id": integrations.customer, "currencyCode": currency}}]} + return await original(**kwargs) + + integrations.google_ads_call = call + run_id = str(db.run.id) + await activities.prepare(run_id) + with pytest.raises(ApplicationError, match="USD"): + await activities.read(run_id) + assert not integrations.mutations + assert not model.calls + + +@pytest.mark.parametrize( + "reason", ["spending_limit", "provider_result_unavailable", "billing_refused"] +) +async def test_unavailable_account_read_keeps_its_reason(reason): + activities, db, storage, integrations, model = monitor_fixture() + run_id = str(db.run.id) + await activities.prepare(run_id) + + async def unavailable(*args, **kwargs): + return { + "status": "unavailable", + "reason": reason, + "billing_error_code": "unmetered_operation" if reason == "billing_refused" else None, + } + + activities._ads = unavailable + with pytest.raises(ApplicationError, match=reason) as caught: + await activities.read(run_id) + assert not integrations.mutations and not model.calls + await activities.failure(run_id) + message = db.project_failure.await_args.kwargs["error_message"] + expected = { + "spending_limit": "spending limit", + "provider_result_unavailable": "provider_result_unavailable", + "billing_refused": "unmetered_operation", + }[reason] + assert expected in message + assert "account read" in message and "model call" not in message + assert caught.value.non_retryable is (reason != "provider_result_unavailable") diff --git a/tests/test_paid_ads_monitor_activities.py b/tests/test_paid_ads_monitor_activities.py index f19c04c6..e4b35013 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" @@ -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()