Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/paid-ads-launch-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/tin_lite/paid_ads_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"campaign_exists",
"account_disabled",
"not_recommended",
"unsupported_currency",
)
LAUNCHABLE = {"test", "continue", "go", "restart", "restructure"}
LIMITS = {
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions src/tin_lite/paid_ads_monitor_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
89 changes: 89 additions & 0 deletions tests/test_paid_ads_currency.py
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 4 additions & 2 deletions tests/test_paid_ads_monitor_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
Loading